fix(ui): use searchable agent picker for secret access (#9918)
## 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 <noreply@paperclip.ing>
This commit is contained in:
parent
631cca509d
commit
b9f4073a13
|
|
@ -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(<AgentSelect agents={agents} value="" onChange={onChange} />);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const filter = document.body.querySelector<HTMLInputElement>('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) => ({
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setFilter("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("w-full justify-between", triggerClassName)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={cn("min-w-0 truncate", !selectedAgent && "text-muted-foreground")}>
|
||||
{selectedAgent?.name ?? placeholder}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
||||
<div className="border-b border-border p-3">
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Filter agents"
|
||||
className="h-8"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-y-auto py-1">
|
||||
{filteredAgents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className="flex w-full items-start gap-2 px-3 py-2 text-left hover:bg-accent/30"
|
||||
aria-label={`Select ${agent.name}`}
|
||||
onClick={() => {
|
||||
onChange(agent.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<AgentIcon icon={agent.icon ?? null} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-foreground">{agent.name}</span>
|
||||
{agent.title ? <span className="truncate text-xs text-muted-foreground">{agent.title}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">No matches.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentMultiSelect({
|
||||
agents,
|
||||
selectedAgentIds,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</label>
|
||||
<select
|
||||
<AgentSelect
|
||||
id="agent-access-agent"
|
||||
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs outline-none"
|
||||
agents={grantableAgents}
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => setSelectedAgentId(event.target.value)}
|
||||
>
|
||||
<option value="">Select agent…</option>
|
||||
{grantableAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setSelectedAgentId}
|
||||
triggerClassName="h-8 text-xs"
|
||||
emptyMessage="No agents available."
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
|
|
|
|||
Loading…
Reference in New Issue