fix(ui): move agent secret access to searchable secrets tab (#11283)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The agent configuration UI controls each agent and its allowed
secrets.
> - The environment variable editor already has a secret selector with
search and folder navigation.
> - The secret access editor used a basic list and made large secret
stores hard to use.
> - The secret access controls also occupied the main Configuration tab.
> - This pull request reuses the rich selector and moves secret access
to a dedicated Secrets tab.
> - The benefit is one consistent secret selection workflow with clearer
agent configuration navigation.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The agent detail configuration view and its secret access editor.

**Subsystem affected**

`ui/` — React and Vite board UI.

**Current behavior**

The secret access editor uses a basic select control. It does not
provide the search and folder navigation available in the environment
variable editor. The editor also appears inside the Configuration tab.

**Proposed behavior**

The secret access editor uses the shared secret picker. Users can search
secrets and browse slash-delimited folders. Agent details provide a
dedicated Secrets tab for this editor.

**Reason and benefit**

Large secret stores are slow to scan in a flat list. Reusing one
selector reduces UI differences and makes scoped secret access easier to
manage.

**Breaking changes**

None. The API and saved secret access data do not change.

## What Changed

- Reused the environment variable secret picker in the agent secret
access editor.
- Preserved secret version selection and the create-secret action,
including nested-popover focus handling.
- Added a route-backed Secrets tab to agent details and removed secret
access controls from Configuration.
- Guarded unsaved configuration across tab, link, browser-history, and
action-triggered navigation.
- Rechecked dirty state when navigation-producing agent actions finish,
covering edits made while a request is pending.
- Added component, page, and Storybook coverage for the workflow.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/AgentActionButtons.test.tsx
src/components/AgentConfigForm.render.test.tsx
src/components/AgentSecretAccessEditor.test.tsx
src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx
src/pages/AgentDetail.progress.test.ts` — 82 tests passed.
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- All GitHub PR checks passed on `5209c5b787`, including build, canary,
general and serialized tests, and all three e2e shards.
- Greptile completed at 5/5 with zero unresolved review threads.

## Risks

- Low risk. The API and persisted binding format are unchanged; this
changes agent configuration navigation and secret selection UI.
- Dirty-state guards now cover direct navigation, Back/Forward history,
and navigation-producing agent actions, including pending-request races.
- Tests cover tab separation, secret access updates, search, folder
navigation, focus restoration, and navigation rejection.
- No documentation change is required because commands, contracts, and
setup steps do not change.

> 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 with GPT-5. This runtime did not expose a more specific
model ID or context window. The model used agentic reasoning, repository
tools, and code execution.

## 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:
Dotta 2026-08-14 08:03:20 -04:00 committed by GitHub
parent aac6ce82e1
commit dc6fcd1ff1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 629 additions and 108 deletions

View File

@ -126,7 +126,7 @@ describe("AgentActionButtons", () => {
});
function render(agent: Agent, props: Partial<ComponentProps<typeof AgentActionButtons>> = {}) {
root = createRoot(container);
root ??= createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<AgentActionButtons agent={agent} companyId="company-1" runLabel="Run Heartbeat" {...props} />
@ -205,4 +205,64 @@ describe("AgentActionButtons", () => {
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] });
});
it("does not terminate when navigation away from a dirty detail page is rejected", async () => {
const onBeforeNavigate = vi.fn().mockReturnValue(false);
render(makeAgent(), { onBeforeNavigate, onTerminateSuccess: vi.fn() });
await flushReact();
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
});
await flushReact();
const terminateButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Terminate"));
await act(async () => {
terminateButton?.click();
});
await flushReact();
expect(onBeforeNavigate).toHaveBeenCalledOnce();
expect(mockAgentsApi.terminate).not.toHaveBeenCalled();
});
it("rechecks navigation when the form becomes dirty while termination is pending", async () => {
let resolveTermination!: (agent: Agent) => void;
mockAgentsApi.terminate.mockReturnValue(new Promise((resolve) => {
resolveTermination = resolve;
}));
const onBeforeNavigate = vi.fn().mockReturnValueOnce(true).mockReturnValue(false);
const onTerminateSuccess = vi.fn();
const agent = makeAgent();
render(agent, {
hasPendingNavigationChanges: false,
onBeforeNavigate,
onTerminateSuccess,
});
await flushReact();
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
});
await flushReact();
const terminateButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Terminate"));
await act(async () => {
terminateButton?.click();
});
await flushReact();
render(agent, {
hasPendingNavigationChanges: true,
onBeforeNavigate,
onTerminateSuccess,
});
await flushReact();
resolveTermination(makeAgent({ status: "terminated" }));
await flushReact();
expect(onBeforeNavigate).toHaveBeenCalledTimes(2);
expect(onTerminateSuccess).not.toHaveBeenCalled();
});
});

View File

@ -1,4 +1,4 @@
import { useCallback, useState, type ReactNode } from "react";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "@/lib/router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
@ -165,6 +165,8 @@ export function AgentActionButtons({
workActionsDisabled = false,
workActionsDisabledReason,
navigateToRunOnInvoke = true,
hasPendingNavigationChanges = false,
onBeforeNavigate,
onActionError,
onTerminateSuccess,
pauseConfirm,
@ -182,6 +184,10 @@ export function AgentActionButtons({
workActionsDisabled?: boolean;
workActionsDisabledReason?: string;
navigateToRunOnInvoke?: boolean;
/** Whether the caller currently has an unsaved draft that navigation would discard. */
hasPendingNavigationChanges?: boolean;
/** Return false to stop an action whose success would navigate away. */
onBeforeNavigate?: () => boolean;
/**
* When set, pausing prompts a confirmation dialog first (e.g. for built-in
* agents that power a feature). Omit for the immediate-pause default.
@ -207,6 +213,25 @@ export function AgentActionButtons({
const { pushToast } = useToastActions();
const [moreOpen, setMoreOpen] = useState(false);
const [pauseConfirmOpen, setPauseConfirmOpen] = useState(false);
const pendingNavigationChangesRef = useRef(hasPendingNavigationChanges);
const beforeNavigateRef = useRef(onBeforeNavigate);
const agentActionStartedDirtyRef = useRef(false);
const duplicateStartedDirtyRef = useRef(false);
pendingNavigationChangesRef.current = hasPendingNavigationChanges;
beforeNavigateRef.current = onBeforeNavigate;
function confirmNavigationStart(startedDirtyRef: React.MutableRefObject<boolean>) {
startedDirtyRef.current = pendingNavigationChangesRef.current;
return beforeNavigateRef.current?.() !== false;
}
function confirmLateNavigationChanges(startedDirtyRef: React.MutableRefObject<boolean>) {
return (
!pendingNavigationChangesRef.current ||
startedDirtyRef.current ||
beforeNavigateRef.current?.() !== false
);
}
const resolvedCompanyId = companyId ?? agent.companyId;
const canonicalAgentRef = agentRouteRef(agent);
@ -251,9 +276,11 @@ export function AgentActionButtons({
onActionError?.(null);
invalidateAgent();
if (action === "terminate") {
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
onTerminateSuccess?.(data as Agent);
}
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
}
},
@ -285,6 +312,7 @@ export function AgentActionButtons({
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" });
if (!confirmLateNavigationChanges(duplicateStartedDirtyRef)) return;
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
},
onError: (err) => {
@ -299,7 +327,7 @@ export function AgentActionButtons({
const nextName = duplicateAgentName(agent.name);
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
setMoreOpen(false);
if (!confirmed) return;
if (!confirmed || !confirmNavigationStart(duplicateStartedDirtyRef)) return;
duplicateAgent.mutate();
}, [agent.name, duplicateAgent]);
@ -334,7 +362,10 @@ export function AgentActionButtons({
<span className="hidden sm:inline">{assignLabel}</span>
</Button>
<RunButton
onClick={() => agentAction.mutate("invoke")}
onClick={() => {
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
agentAction.mutate("invoke");
}}
disabled={assignAndRunDisabled}
label={runLabel}
size={size}
@ -423,8 +454,9 @@ export function AgentActionButtons({
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
agentAction.mutate("terminate");
setMoreOpen(false);
if (onTerminateSuccess && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
agentAction.mutate("terminate");
}}
>
<Trash2 className="h-3 w-3" />

View File

@ -205,7 +205,10 @@ function setInputValue(input: HTMLInputElement, value: string) {
async function renderForm(
environments: Environment[],
agentOverrides: Partial<Agent> = {},
options: { showAdapterTestEnvironmentButton?: boolean } = {},
options: {
showAdapterTestEnvironmentButton?: boolean;
content?: "configuration" | "secrets";
} = {},
) {
mockEnvironmentsApi.list.mockResolvedValue(environments);
@ -229,6 +232,7 @@ async function renderForm(
agent={makeAgent(agentOverrides)}
onSave={vi.fn()}
hidePromptTemplate
content={options.content}
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
@ -418,6 +422,28 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.querySelector("select")).toBeNull();
});
it("keeps secret access out of the main Configuration content", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
]);
roots.push(result.root);
expect(result.container.textContent).not.toContain("Secret access");
});
it("renders secret access as dedicated form content", async () => {
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{},
{ content: "secrets" },
);
roots.push(result.root);
expect(result.container.textContent).toContain("Secret access");
expect(result.container.textContent).toContain("No secrets are bound to this agent yet.");
expect(result.container.textContent).not.toContain("Environment variables");
});
it("shows concise Environment copy when one runnable non-local environment exists", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),

View File

@ -102,6 +102,8 @@ type AgentConfigFormProps = {
hideInstructionsFile?: boolean;
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
hidePromptTemplate?: boolean;
/** Render the main configuration sections or the dedicated edit-only Secrets surface. */
content?: "configuration" | "secrets";
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
} & (
@ -1051,6 +1053,43 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
});
}
if (!isCreate && props.content === "secrets") {
return (
<div className={cn("relative", cards && "space-y-6")}>
{isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-primary/20 bg-background/90 px-4 py-2 backdrop-blur-sm">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Unsaved changes</span>
<Button size="sm" onClick={handleSave} disabled={props.isSaving}>
{props.isSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="mb-3 text-sm font-medium">Secret access</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Secret access</div>
}
<div className={cn(cards ? "space-y-3 rounded-lg border border-border p-4" : "space-y-3 px-4 pb-3")}>
<p className="text-xs text-muted-foreground">{help.secretAccess}</p>
<AgentSecretAccessEditor
config={{ ...config, ...overlay.adapterConfig }}
secrets={availableSecrets}
onChange={applyAccessGrants}
onCreateSecret={(name, value) => createSecret.mutateAsync({ name, value })}
proposals={agentBindingProposals}
onApproveProposal={proposalReview.requestApprove}
onRejectProposal={proposalReview.requestReject}
/>
{proposalReview.dialogs}
</div>
</div>
</div>
);
}
return (
<div className={cn("relative", cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
@ -1538,20 +1577,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
/>
</Field>
{!isCreate && (
<Field label="Secret access" hint={help.secretAccess}>
<AgentSecretAccessEditor
config={{ ...config, ...overlay.adapterConfig }}
secrets={availableSecrets}
onChange={applyAccessGrants}
proposals={agentBindingProposals}
onApproveProposal={proposalReview.requestApprove}
onRejectProposal={proposalReview.requestReject}
/>
{proposalReview.dialogs}
</Field>
)}
{/* Edit-only: timeout + grace period */}
{!isCreate && (
<>

View File

@ -5,18 +5,29 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanySecret, EnvSecretRefBinding } from "@paperclipai/shared";
// Stub SecretBindingPicker so the editor renders without CompanyContext /
// react-query. The stub exposes a button that binds a fixed secret.
vi.mock("./SecretBindingPicker", () => ({
SecretBindingPicker: ({
onChange,
}: {
onChange: (next: { secretId: string; version?: number | "latest" } | null) => void;
}) => (
<button type="button" data-testid="pick-secret" onClick={() => onChange({ secretId: "s1", version: "latest" })}>
pick
</button>
),
const mockSecretPickerRender = vi.hoisted(() => vi.fn());
// Keep this component test focused on access-row behavior while asserting that
// the editor routes selection through the shared, folder-aware env picker.
vi.mock("./environment-variables-editor/SecretPicker", () => ({
SecretPicker: (props: {
secretId: string;
secrets: readonly CompanySecret[];
onSelect: (secretId: string) => void;
onCreateNew?: (query: string) => void;
}) => {
mockSecretPickerRender(props);
return <>
<button type="button" data-testid="pick-secret" onClick={() => props.onSelect("s1")}>
pick
</button>
{props.onCreateNew ? (
<button type="button" data-testid="create-secret" onClick={() => props.onCreateNew?.("new_secret")}>
create
</button>
) : null}
</>;
},
}));
import {
@ -155,12 +166,43 @@ describe("AgentSecretAccessEditor component", () => {
// Bind a secret via the stubbed picker.
const pick = container.querySelector<HTMLButtonElement>('[data-testid="pick-secret"]')!;
expect(mockSecretPickerRender).toHaveBeenLastCalledWith(
expect.objectContaining({ secretId: "", secrets }),
);
flushSync(() => pick.click());
const last = emitted.at(-1)!;
expect(last).toEqual({ STRIPE: { type: "secret_ref", secretId: "s1", version: "latest" } });
});
it("keeps the create form open when focus returns to the shared picker anchor", async () => {
vi.useFakeTimers();
try {
render(
<AgentSecretAccessEditor
config={{ "access.STRIPE": { type: "secret_ref", secretId: "s1" } }}
secrets={secrets}
onChange={() => {}}
onCreateSecret={async () => secrets[0]!}
/>,
);
const createButton = container.querySelector<HTMLButtonElement>('[data-testid="create-secret"]')!;
flushSync(() => createButton.click());
flushSync(() => vi.runAllTimers());
expect(document.body.textContent).toContain("Create secret");
const pickerButton = container.querySelector<HTMLButtonElement>('[data-testid="pick-secret"]')!;
pickerButton.focus();
flushSync(() => {});
expect(document.body.textContent).toContain("Create secret");
expect(document.querySelector('input[aria-label="Secret name"]')).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("renders pending binding proposals as Proposed rows with approve/reject", () => {
const approved: string[] = [];
const rejected: string[] = [];

View File

@ -9,14 +9,16 @@ import type {
import { cn } from "../lib/utils";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker";
import { SecretPicker } from "./environment-variables-editor/SecretPicker";
import { CreateSecretPopover } from "./environment-variables-editor/CreateSecretPopover";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import {
AGENT_ACCESS_CONFIG_PATH_PREFIX,
ENV_CONFIG_PATH_PREFIX,
SECRET_ALIAS_RE,
deliveryModeDescription,
} from "../lib/secret-delivery";
import { envKeyFromSecretName } from "./environment-variables-editor/model";
import { envKeyFromSecretName, secretNameFromKey } from "./environment-variables-editor/model";
import {
DeliveryBadge as ProposalDeliveryBadge,
ProposalActions,
@ -157,6 +159,8 @@ export interface AgentSecretAccessEditorProps {
* parent diffs this against the current `access.*` keys to add/remove them.
*/
onChange: (next: Record<string, EnvSecretRefBinding>) => void;
/** Create a company secret from the shared picker's pinned create action. */
onCreateSecret?: (name: string, value: string) => Promise<CompanySecret>;
disabled?: boolean;
/** Pending binding proposals targeting this agent (PAP-14731). */
proposals?: readonly SecretProposalView[];
@ -191,6 +195,7 @@ export function AgentSecretAccessEditor({
config,
secrets,
onChange,
onCreateSecret,
disabled,
proposals,
onApproveProposal,
@ -208,6 +213,8 @@ export function AgentSecretAccessEditor({
const incomingKey = useMemo(() => normalizeAccessMapKey(incomingMap), [incomingMap]);
const [rows, setRows] = useState<AccessRow[]>(() => entriesToRows(apiBindings));
const [createRequest, setCreateRequest] = useState<{ rowId: string; name: string } | null>(null);
const pickerAnchorRefs = useRef(new Map<string, HTMLDivElement>());
const lastEmittedKeyRef = useRef(incomingKey);
const lastIncomingKeyRef = useRef(incomingKey);
@ -334,9 +341,7 @@ export function AgentSecretAccessEditor({
const trimmedAlias = row.alias.trim();
const aliasInvalid = Boolean(trimmedAlias) && !SECRET_ALIAS_RE.test(trimmedAlias);
const aliasDuplicate = Boolean(trimmedAlias) && (aliasCounts.get(trimmedAlias) ?? 0) > 1;
const bindingValue: SecretBindingValue | null = row.secretId
? { secretId: row.secretId, version: row.version }
: null;
const selectedSecret = secrets.find((secret) => secret.id === row.secretId) ?? null;
return (
<div key={row.id} className="space-y-1">
<div className="grid grid-cols-(--gtc-65) items-start gap-1.5">
@ -360,23 +365,106 @@ export function AgentSecretAccessEditor({
)}
/>
</div>
<div>
<SecretBindingPicker
value={bindingValue}
onChange={(next) =>
<div className="flex min-w-0 items-start gap-1.5">
<Popover
open={createRequest?.rowId === row.id}
onOpenChange={(open) => {
if (!open && createRequest?.rowId === row.id) setCreateRequest(null);
}}
>
<PopoverAnchor asChild>
<div
ref={(node) => {
if (node) pickerAnchorRefs.current.set(row.id, node);
else pickerAnchorRefs.current.delete(row.id);
}}
className="min-w-0 flex-1"
>
<SecretPicker
secretId={row.secretId}
secrets={secrets}
onSelect={(secretId) =>
patchRow(row.id, {
secretId,
version: "latest",
alias:
!row.alias.trim() && secretId
? envKeyFromSecretName(secretName(secretId))
: row.alias,
})
}
onCreateNew={onCreateSecret
? (query) => {
window.setTimeout(() => {
setCreateRequest({
rowId: row.id,
name: secretNameFromKey(query) || query.trim(),
});
}, 0);
}
: undefined}
disabled={disabled}
triggerClassName="h-9 min-h-9"
/>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-auto p-3"
onInteractOutside={(event) => {
// Closing the picker returns focus to its trigger inside
// this popover's anchor. Keep that focus restoration from
// dismissing the create form that just opened.
const target = event.detail.originalEvent.target as Node | null;
if (target && pickerAnchorRefs.current.get(row.id)?.contains(target)) {
event.preventDefault();
}
}}
>
{createRequest?.rowId === row.id && onCreateSecret ? (
<CreateSecretPopover
initialName={createRequest.name}
initialValue=""
existingSecretNames={secrets.map((secret) => secret.name)}
onCancel={() => setCreateRequest(null)}
onSubmit={async (name, value) => {
const created = await onCreateSecret(name, value);
patchRow(row.id, {
secretId: created.id,
version: "latest",
alias: row.alias.trim() || envKeyFromSecretName(created.name),
});
setCreateRequest(null);
}}
/>
) : null}
</PopoverContent>
</Popover>
<select
className="h-9 shrink-0 rounded-md border border-border bg-background px-2 text-xs outline-none disabled:cursor-not-allowed disabled:opacity-60"
value={row.version === undefined ? "latest" : String(row.version)}
onChange={(event) => {
const raw = event.target.value;
patchRow(row.id, {
secretId: next?.secretId ?? "",
version: next?.version ?? "latest",
alias:
!row.alias.trim() && next?.secretId
? envKeyFromSecretName(secretName(next.secretId))
: row.alias,
})
}
label=""
placeholder="Select secret"
disabled={disabled}
/>
version: raw === "latest" ? "latest" : Number.parseInt(raw, 10),
});
}}
disabled={disabled || !selectedSecret}
aria-label="Version"
>
<option value="latest">latest</option>
{selectedSecret
? Array.from({ length: Math.max(0, selectedSecret.latestVersion) }, (_, index) => {
const version = selectedSecret.latestVersion - index;
if (version <= 0) return null;
return (
<option key={version} value={version}>
v{version}
</option>
);
})
: null}
</select>
</div>
<button
type="button"

View File

@ -8,7 +8,7 @@ export interface PageTabItem {
}
interface PageTabBarProps {
items: PageTabItem[];
items: readonly PageTabItem[];
value?: string;
onValueChange?: (value: string) => void;
align?: "center" | "start";

View File

@ -132,7 +132,7 @@ export interface SecretPickerProps {
disabled?: boolean;
onSelect: (secretId: string) => void;
/** Open the create-secret popover, seeded with the current query. */
onCreateNew: (query: string) => void;
onCreateNew?: (query: string) => void;
triggerClassName?: string;
/** SearchableSelect auto-opens on focus; suppress for programmatic control. */
disablePortal?: boolean;
@ -317,21 +317,23 @@ export function SecretPicker({
</span>
);
}}
createItem={{
render: (query) => (
<span className="flex items-center gap-1.5 text-sm">
<Plus className="size-3.5 shrink-0" />
{query.trim() ? (
<span>
Create secret <span className="font-mono">&ldquo;{query.trim()}&rdquo;</span>
createItem={onCreateNew
? {
render: (query) => (
<span className="flex items-center gap-1.5 text-sm">
<Plus className="size-3.5 shrink-0" />
{query.trim() ? (
<span>
Create secret <span className="font-mono">&ldquo;{query.trim()}&rdquo;</span>
</span>
) : (
<span>Create new secret</span>
)}
</span>
) : (
<span>Create new secret</span>
)}
</span>
),
onSelect: (query) => onCreateNew(query),
}}
),
onSelect: (query) => onCreateNew(query),
}
: undefined}
/>
);
}

View File

@ -3,13 +3,65 @@ import { describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import {
AGENT_DETAIL_TABS,
DISCARD_AGENT_CONFIG_CHANGES_MESSAGE,
agentConfigHistoryRestoreDelta,
buildHeartbeatProgressLogLine,
confirmAgentConfigNavigation,
heartbeatProgressLogLineKey,
parseAgentDetailView,
restoreAgentConfigHistoryEntry,
runDetailRefetchIntervalMs,
shouldPollRunShellLog,
syncAgentRouteAfterRename,
} from "./AgentDetail";
describe("agent detail tabs", () => {
it("exposes Secrets as its own route-backed tab", () => {
expect(AGENT_DETAIL_TABS.map((tab) => tab.label)).toContain("Secrets");
expect(parseAgentDetailView("secrets")).toBe("secrets");
});
it("requires confirmation before navigation can discard unsaved configuration", () => {
const confirm = vi.fn().mockReturnValue(false);
expect(confirmAgentConfigNavigation(true, confirm)).toBe(false);
expect(confirm).toHaveBeenCalledWith(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE);
confirm.mockReturnValue(true);
expect(confirmAgentConfigNavigation(true, confirm)).toBe(true);
expect(confirmAgentConfigNavigation(false, confirm)).toBe(true);
expect(confirm).toHaveBeenCalledTimes(2);
});
it("restores the prior history entry when Back or Forward navigation is rejected", () => {
expect(agentConfigHistoryRestoreDelta(4, 2)).toBe(2);
expect(agentConfigHistoryRestoreDelta(2, 4)).toBe(-2);
expect(agentConfigHistoryRestoreDelta(2, 2)).toBeNull();
expect(agentConfigHistoryRestoreDelta(undefined, 1)).toBeNull();
});
it("restores the guarded URL when React Router history indexes are unavailable", () => {
const history = {
go: vi.fn(),
pushState: vi.fn(),
};
const currentState = { key: "agent-config" };
expect(restoreAgentConfigHistoryEntry(history, {
index: undefined,
state: currentState,
url: "https://paperclip.test/agents/eng/secrets",
}, undefined)).toBe(false);
expect(history.pushState).toHaveBeenCalledWith(
currentState,
"",
"https://paperclip.test/agents/eng/secrets",
);
expect(history.go).not.toHaveBeenCalled();
});
});
describe("buildHeartbeatProgressLogLine", () => {
it("renders progress messages with phase prefixes as system log lines", () => {
expect(

View File

@ -275,11 +275,58 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "tools" | "runs" | "audit" | "budget";
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "secrets" | "skills" | "tools" | "runs" | "audit" | "budget";
function parseAgentDetailView(value: string | null): AgentDetailView {
export const AGENT_DETAIL_TABS: ReadonlyArray<{ value: AgentDetailView; label: string }> = [
{ value: "dashboard", label: "Dashboard" },
{ value: "instructions", label: "Instructions" },
{ value: "skills", label: "Skills" },
{ value: "configuration", label: "Configuration" },
{ value: "secrets", label: "Secrets" },
{ value: "tools", label: "Tools" },
{ value: "runs", label: "Runs" },
{ value: "audit", label: "Audit" },
{ value: "budget", label: "Budget" },
];
export const DISCARD_AGENT_CONFIG_CHANGES_MESSAGE = "Discard unsaved agent configuration changes?";
export function confirmAgentConfigNavigation(
dirty: boolean,
confirm: (message: string) => boolean = (message) =>
typeof window === "undefined" || window.confirm(message),
): boolean {
return !dirty || confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE);
}
export function agentConfigHistoryRestoreDelta(currentIndex: unknown, nextIndex: unknown): number | null {
if (typeof currentIndex !== "number" || typeof nextIndex !== "number") return null;
const delta = currentIndex - nextIndex;
return delta === 0 ? null : delta;
}
export function restoreAgentConfigHistoryEntry(
history: Pick<History, "go" | "pushState">,
currentEntry: { index: unknown; state: unknown; url: string },
nextIndex: unknown,
): boolean {
const restoreDelta = agentConfigHistoryRestoreDelta(currentEntry.index, nextIndex);
if (restoreDelta === null) {
// Some legacy URL-cleanup paths erased React Router's history index. A
// fresh copy of the guarded entry is the only safe way to return without
// letting Router consume the unindexed destination and discard the form.
history.pushState(currentEntry.state, "", currentEntry.url);
return false;
}
history.go(restoreDelta);
return true;
}
export function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "instructions" || value === "prompts") return "instructions";
if (value === "configure" || value === "configuration") return "configuration";
if (value === "secrets") return "secrets";
if (value === "skills") return "skills";
if (value === "tools") return "tools";
if (value === "budget") return "budget";
@ -732,6 +779,9 @@ export function AgentDetail() {
const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId));
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
const prepareAgentNavigation = useCallback(() => {
return confirmAgentConfigNavigation(configDirty);
}, [configDirty]);
const { data: agent, isLoading, error } = useQuery<AgentDetailRecord>({
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
@ -907,17 +957,19 @@ export function AgentDetail() {
? "instructions"
: activeView === "configuration"
? "configuration"
: activeView === "skills"
? "skills"
: activeView === "tools"
? "tools"
: activeView === "runs"
? "runs"
: activeView === "audit"
? "audit"
: activeView === "budget"
? "budget"
: "dashboard";
: activeView === "secrets"
? "secrets"
: activeView === "skills"
? "skills"
: activeView === "tools"
? "tools"
: activeView === "runs"
? "runs"
: activeView === "audit"
? "audit"
: activeView === "budget"
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
return;
@ -1018,6 +1070,8 @@ export function AgentDetail() {
crumbs.push({ label: "Instructions" });
} else if (activeView === "configuration") {
crumbs.push({ label: "Configuration" });
} else if (activeView === "secrets") {
crumbs.push({ label: "Secrets" });
// } else if (activeView === "skills") { // TODO: bring back later
// crumbs.push({ label: "Skills" });
} else if (activeView === "tools") {
@ -1056,6 +1110,74 @@ export function AgentDetail() {
}, [configDirty]),
);
useEffect(() => {
if (!configDirty) return;
function handleDocumentClick(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target && anchor.target !== "_self") return;
const nextUrl = new URL(anchor.href, window.location.href);
const currentUrl = new URL(window.location.href);
if (nextUrl.origin !== currentUrl.origin) return;
if (
nextUrl.pathname === currentUrl.pathname &&
nextUrl.search === currentUrl.search &&
nextUrl.hash === currentUrl.hash
) {
return;
}
if (prepareAgentNavigation()) return;
event.preventDefault();
event.stopPropagation();
}
document.addEventListener("click", handleDocumentClick, true);
return () => document.removeEventListener("click", handleDocumentClick, true);
}, [configDirty, prepareAgentNavigation]);
useEffect(() => {
if (!configDirty) return;
// BrowserRouter updates after popstate. Run first in the capture phase so a
// rejected Back/Forward navigation can be restored before React Router
// consumes it and unmounts the route-backed form.
const currentEntry = {
index: window.history.state?.idx,
state: window.history.state,
url: window.location.href,
};
let restoring = false;
function handlePopState(event: PopStateEvent) {
if (restoring) {
restoring = false;
return;
}
if (prepareAgentNavigation()) return;
event.stopImmediatePropagation();
restoring = restoreAgentConfigHistoryEntry(window.history, currentEntry, event.state?.idx);
}
window.addEventListener("popstate", handlePopState, true);
return () => window.removeEventListener("popstate", handlePopState, true);
}, [configDirty, prepareAgentNavigation]);
if (isLoading) return <PageSkeleton variant="detail" />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!agent) return null;
@ -1065,7 +1187,9 @@ export function AgentDetail() {
const isPendingApproval = agent.status === "pending_approval";
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
const pausedEscalationWarning = !hasInvalidOrgChain ? agent.orgChainHealth?.escalationWarning ?? null : null;
const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving);
const showConfigActionBar = (
activeView === "configuration" || activeView === "instructions" || activeView === "secrets"
) && (configDirty || configSaving);
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
const agentMembershipPending =
membershipMutation.isPending &&
@ -1075,6 +1199,11 @@ export function AgentDetail() {
const agentStarPending = agentMembershipPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined;
function handleAgentTabChange(value: string) {
if (value === activeView || !prepareAgentNavigation()) return;
navigate(`/agents/${canonicalAgentRef}/${value}`);
}
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
{showLeftAgentNotice ? (
@ -1183,6 +1312,8 @@ export function AgentDetail() {
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
hasPendingNavigationChanges={configDirty}
onBeforeNavigate={prepareAgentNavigation}
onActionError={setActionError}
onTerminateSuccess={() => navigate("/agents/all", { replace: true })}
hideTerminate={Boolean(builtInState)}
@ -1268,21 +1399,12 @@ export function AgentDetail() {
{!urlRunId && (
<Tabs
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
onValueChange={handleAgentTabChange}
>
<PageTabBar
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "instructions", label: "Instructions" },
{ value: "skills", label: "Skills" },
{ value: "configuration", label: "Configuration" },
{ value: "tools", label: "Tools" },
{ value: "runs", label: "Runs" },
{ value: "audit", label: "Audit" },
{ value: "budget", label: "Budget" },
]}
items={AGENT_DETAIL_TABS}
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
onValueChange={handleAgentTabChange}
/>
</Tabs>
)}
@ -1388,6 +1510,21 @@ export function AgentDetail() {
/>
)}
{activeView === "secrets" && (
<div className="max-w-3xl">
<ConfigurationTab
agent={agent}
companyId={resolvedCompanyId ?? undefined}
onDirtyChange={setConfigDirty}
onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving}
updatePermissions={updatePermissions}
content="secrets"
/>
</div>
)}
{activeView === "skills" && (
<AgentSkillsTab
agent={agent}
@ -1935,6 +2072,7 @@ function ConfigurationTab({
updatePermissions,
hidePromptTemplate,
hideInstructionsFile,
content = "configuration",
}: {
agent: AgentDetailRecord;
companyId?: string;
@ -1945,6 +2083,7 @@ function ConfigurationTab({
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
hidePromptTemplate?: boolean;
hideInstructionsFile?: boolean;
content?: "configuration" | "secrets";
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
@ -1959,7 +2098,7 @@ function ConfigurationTab({
? queryKeys.agents.adapterModels(companyId, agent.adapterType)
: ["agents", "none", "adapter-models", agent.adapterType],
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType),
enabled: Boolean(companyId),
enabled: Boolean(companyId) && content === "configuration",
});
const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review";
@ -1967,7 +2106,7 @@ function ConfigurationTab({
const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({
queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__low-trust-disabled"],
queryFn: () => projectsApi.list(companyId!),
enabled: Boolean(companyId && lowTrustSelected),
enabled: Boolean(companyId && lowTrustSelected) && content === "configuration",
});
const { data: boundaryIssues, isLoading: boundaryIssuesLoading } = useQuery({
@ -1975,7 +2114,7 @@ function ConfigurationTab({
? [...queryKeys.issues.list(companyId), "low-trust-boundary-candidates"]
: ["issues", "__low-trust-disabled"],
queryFn: () => issuesApi.list(companyId!, { limit: 100, sortField: "updated", sortDir: "desc" }),
enabled: Boolean(companyId && lowTrustSelected),
enabled: Boolean(companyId && lowTrustSelected) && content === "configuration",
});
const updateAgent = useMutation({
@ -1987,7 +2126,7 @@ function ConfigurationTab({
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, urlTab ?? "configuration")) {
if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, urlTab ?? content)) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
}
pushToast({ title: "Agent saved", tone: "success" });
@ -2046,13 +2185,16 @@ function ConfigurationTab({
hideInlineSave
hidePromptTemplate={hidePromptTemplate}
hideInstructionsFile={hideInstructionsFile}
content={content}
sectionLayout="cards"
/>
<p className="text-xs text-muted-foreground">
Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session.
</p>
{content === "configuration" ? (
<p className="text-xs text-muted-foreground">
Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session.
</p>
) : null}
<TrustPresetSection
{content === "configuration" ? <TrustPresetSection
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
@ -2073,9 +2215,9 @@ function ConfigurationTab({
...buildPermissionsForTrustPreset(nextPermissions, nextPermissions.trustPreset === "low_trust_review" ? "low_trust_review" : "standard"),
})
}
/>
/> : null}
<div>
{content === "configuration" ? <div>
<h3 className="text-sm font-medium mb-3">Permissions</h3>
<div className="border border-border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between gap-4 text-sm">
@ -2136,7 +2278,7 @@ function ConfigurationTab({
/>
</div>
</div>
</div>
</div> : null}
</div>
);
}

View File

@ -297,8 +297,32 @@ const storybookSecrets: CompanySecret[] = [
createdByAgentId: "agent-cto",
createdByUserId: null,
createdAt: recent(12_000),
updatedAt: recent(80),
},
updatedAt: recent(80),
},
{
id: "secret-prod-database",
companyId: COMPANY_ID,
scope: "company",
ownerUserId: null,
userSecretDefinitionId: null,
key: "/paperclip-cloud/prod/database/url",
name: "/paperclip-cloud/prod/database/url",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 2,
description: "Production database URL grouped under its secret folder path.",
lastResolvedAt: recent(30),
lastRotatedAt: recent(8_000),
deletedAt: null,
createdByAgentId: "agent-cto",
createdByUserId: null,
createdAt: recent(8_000),
updatedAt: recent(30),
},
];
const adapterFixtures: AdapterInfo[] = [
@ -498,6 +522,28 @@ function AgentConfigFormStory() {
);
}
function AgentSecretsFormStory() {
return (
<AgentConfigForm
mode="edit"
agent={agentWith({
id: "agent-secrets-story",
adapterConfig: {
"access.OPENAI": {
type: "secret_ref",
secretId: "secret-openai",
version: "latest",
},
},
})}
onSave={() => undefined}
content="secrets"
sectionLayout="cards"
hideInlineSave
/>
);
}
function IconPickerMatrix() {
const [selectedIcon, setSelectedIcon] = useState("code");
const visibleIcons = AGENT_ICON_NAMES.slice(0, 28);
@ -743,6 +789,12 @@ function AgentManagementStories() {
</div>
</Section>
<Section eyebrow="Agent Secrets tab" title="Searchable API-access secret bindings">
<div className="max-w-4xl">
<AgentSecretsFormStory />
</div>
</Section>
<Section eyebrow="AgentIconPicker" title="Available icon grid with selected state">
<IconPickerMatrix />
</Section>