mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## 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>
176 lines
5.3 KiB
TypeScript
176 lines
5.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AgentMultiSelect, AgentSelect } from "./AgentMultiSelect";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> | undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
function setInputValue(input: HTMLInputElement, value: string) {
|
|
act(() => {
|
|
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
valueSetter?.call(input, value);
|
|
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
|
|
});
|
|
}
|
|
|
|
describe("AgentMultiSelect", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => {
|
|
root?.unmount();
|
|
});
|
|
}
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("keeps agent lists compact and searchable", async () => {
|
|
const onChange = vi.fn();
|
|
const agents = Array.from({ length: 20 }, (_, index) => ({
|
|
id: `agent-${index}`,
|
|
name: index === 17 ? "Search Target" : `Agent ${index}`,
|
|
title: `Role ${index}`,
|
|
}));
|
|
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root?.render(
|
|
<AgentMultiSelect agents={agents} selectedAgentIds={new Set()} onChange={onChange} />,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toBe("Select agents");
|
|
expect(document.body.textContent).not.toContain("Agent 0");
|
|
|
|
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!, "search target");
|
|
await flush();
|
|
|
|
expect(document.body.textContent).toContain("Search Target");
|
|
expect(document.body.textContent).not.toContain("Agent 0");
|
|
|
|
act(() => {
|
|
document.body
|
|
.querySelector('[aria-label="Allow Search Target"]')
|
|
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(onChange).toHaveBeenCalledTimes(1);
|
|
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) => ({
|
|
id: `agent-${index}`,
|
|
name: `Agent ${index}`,
|
|
}));
|
|
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root?.render(
|
|
<AgentMultiSelect
|
|
agents={agents}
|
|
selectedAgentIds={new Set(["agent-0", "agent-1", "agent-2", "agent-3", "agent-4"])}
|
|
onSave={onSave}
|
|
triggerLabel="Add to agent"
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("Agent 0");
|
|
expect(container.textContent).toContain("Agent 2");
|
|
expect(container.textContent).toContain("and 2 more");
|
|
expect(container.textContent).not.toContain("Agent 4");
|
|
|
|
act(() => {
|
|
container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
act(() => {
|
|
document.body
|
|
.querySelector('[aria-label="Allow Agent 5"]')
|
|
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
const save = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent === "Save");
|
|
act(() => {
|
|
save?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(onSave).toHaveBeenCalledWith(new Set(agents.map((agent) => agent.id)));
|
|
});
|
|
});
|