From b9f4073a1328847c06b87672f55567166daccf92 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:00:34 -0500 Subject: [PATCH] fix(ui): use searchable agent picker for secret access (#9918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the control plane people use to manage AI-agent companies. > - Operators need to grant secrets to specific agents safely and efficiently. > - The secrets access sheet previously used a native select while similar agent-assignment surfaces use a searchable picker. > - That inconsistency makes finding an agent slow and error-prone as companies grow. > - This pull request reuses the shared agent picker behavior for single-agent secret access grants. > - The benefit is a consistent, searchable selection experience without changing secret-access semantics. ## Linked Issues or Issue Description Refs #9797 **Problem / motivation:** The in-sheet agent access form introduced in #9797 renders every grantable agent in a native select. In companies with many agents, operators cannot filter by name or title and the experience differs from other agent-selection surfaces. **Proposed solution:** Add a single-select variant beside the existing `AgentMultiSelect`, then use it in the secret access grant form. **Alternatives considered:** Keeping a native select would preserve less code but would not scale or align with existing searchable agent selection. **Roadmap alignment:** This is a focused usability fix for an existing core control-plane surface and does not overlap an unstarted roadmap initiative. ## What Changed - Added `AgentSelect`, a searchable single-agent popover that filters by agent name and title. - Replaced the secret access form's native select with the shared searchable picker. - Added focused coverage for filtering, selecting, callback behavior, and popover closure. ## Verification - `pnpm exec vitest run ui/src/components/AgentMultiSelect.test.tsx` — passes (3 tests). - `pnpm --filter @paperclipai/ui typecheck` — passes. - `pnpm check:token-gates` — branch adds no violations; the command currently fails on five unchanged `#9627` literals already present on `master`. - Manual: open Secrets, choose a secret, add agent access, filter by agent name or title, select the result, and grant access. ## Risks - Low risk: the change is limited to agent selection UI and preserves the existing grant request payload. - The new picker depends on the existing popover/input primitives and resets its filter when closed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-series coding agent (exact runtime model ID and context-window size are not exposed), with reasoning, repository tool use, terminal execution, and GitHub CLI capabilities. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/AgentMultiSelect.test.tsx | 38 +++++++- ui/src/components/AgentMultiSelect.tsx | 96 +++++++++++++++++++++ ui/src/pages/Secrets.render.test.tsx | 15 +++- ui/src/pages/Secrets.tsx | 18 ++-- 4 files changed, 151 insertions(+), 16 deletions(-) diff --git a/ui/src/components/AgentMultiSelect.test.tsx b/ui/src/components/AgentMultiSelect.test.tsx index 26004b70af..82daf82509 100644 --- a/ui/src/components/AgentMultiSelect.test.tsx +++ b/ui/src/components/AgentMultiSelect.test.tsx @@ -3,7 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AgentMultiSelect } from "./AgentMultiSelect"; +import { AgentMultiSelect, AgentSelect } from "./AgentMultiSelect"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -92,6 +92,42 @@ describe("AgentMultiSelect", () => { expect(onChange.mock.calls[0]?.[0]).toEqual(new Set(["agent-17"])); }); + it("filters and selects a single agent", async () => { + const onChange = vi.fn(); + const agents = [ + { id: "agent-1", name: "Alpha", title: "Engineer" }, + { id: "agent-2", name: "Bravo", title: "Researcher" }, + ]; + + root = createRoot(container); + act(() => { + root?.render(); + }); + + act(() => { + container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const filter = document.body.querySelector('input[placeholder="Filter agents"]'); + expect(filter).not.toBeNull(); + setInputValue(filter!, "research"); + await flush(); + + expect(document.body.textContent).toContain("Bravo"); + expect(document.body.textContent).not.toContain("Alpha"); + + act(() => { + document.body + .querySelector('[aria-label="Select Bravo"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(onChange).toHaveBeenCalledWith("agent-2"); + expect(document.body.querySelector('input[placeholder="Filter agents"]')).toBeNull(); + }); + it("previews selected agents and stages changes until save", async () => { const onSave = vi.fn(); const agents = Array.from({ length: 6 }, (_, index) => ({ diff --git a/ui/src/components/AgentMultiSelect.tsx b/ui/src/components/AgentMultiSelect.tsx index b1a693f900..ad0579895c 100644 --- a/ui/src/components/AgentMultiSelect.tsx +++ b/ui/src/components/AgentMultiSelect.tsx @@ -15,6 +15,102 @@ export interface AgentMultiSelectOption { icon?: string | null; } +export function AgentSelect({ + agents, + value, + onChange, + placeholder = "Select agent…", + emptyMessage = "No agents yet.", + disabled = false, + triggerClassName, + id, +}: { + agents: AgentMultiSelectOption[]; + value: string; + onChange: (agentId: string) => void; + placeholder?: string; + emptyMessage?: string; + disabled?: boolean; + triggerClassName?: string; + id?: string; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const selectedAgent = agents.find((agent) => agent.id === value); + const normalizedFilter = filter.trim().toLowerCase(); + const filteredAgents = useMemo( + () => + agents + .filter((agent) => `${agent.name} ${agent.title ?? ""}`.toLowerCase().includes(normalizedFilter)) + .sort((a, b) => a.name.localeCompare(b.name)), + [agents, normalizedFilter], + ); + + return ( + { + setOpen(nextOpen); + if (!nextOpen) setFilter(""); + }} + > + + + + +
+ setFilter(event.target.value)} + placeholder="Filter agents" + className="h-8" + autoFocus + /> +
+ {agents.length === 0 ? ( +
{emptyMessage}
+ ) : ( +
+ {filteredAgents.map((agent) => ( + + ))} + {filteredAgents.length === 0 ? ( +
No matches.
+ ) : null} +
+ )} +
+
+ ); +} + export function AgentMultiSelect({ agents, selectedAgentIds, diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index c4e3aef013..5ec49b8136 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -1357,14 +1357,21 @@ describe("Secrets page layout", () => { expect(document.body.textContent).toContain("Agent access"); expect(document.body.textContent).toContain("Reviewer"); - const agentSelect = document.getElementById("agent-access-agent") as HTMLSelectElement; + const agentSelect = document.getElementById("agent-access-agent") as HTMLButtonElement; const envKeyInput = document.getElementById("agent-access-env-key") as HTMLInputElement; expect(envKeyInput.value).toBe("OPENAI_API_KEY"); - // Agents that already have access are not offered again. - expect(Array.from(agentSelect.options).map((option) => option.textContent)).not.toContain("Reviewer"); await act(async () => { - setSelectValue(agentSelect, "agent-coder"); + agentSelect.click(); + }); + await flushReact(); + + // Agents that already have access are not offered again. + expect(document.body.textContent).toContain("CodexCoder"); + expect(document.body.querySelector('[aria-label="Select Reviewer"]')).toBeNull(); + + await act(async () => { + (document.body.querySelector('[aria-label="Select CodexCoder"]') as HTMLButtonElement | null)?.click(); }); await flushReact(); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 4cf2153da4..0acc27e2b5 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -100,6 +100,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { cn } from "../lib/utils"; import { copyTextToClipboard } from "../lib/clipboard"; import { PageTabBar } from "../components/PageTabBar"; +import { AgentSelect } from "../components/AgentMultiSelect"; import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog"; import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab"; import { SecretPathName } from "./secrets/SecretPathName"; @@ -4398,19 +4399,14 @@ function AgentAccessSection({ > Agent - + onChange={setSelectedAgentId} + triggerClassName="h-8 text-xs" + emptyMessage="No agents available." + />