import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { HelpCircle, PackageCheck } from "lucide-react"; import type { AgentDetail as AgentDetailRecord, ConnectionGrant, ToolCatalogEntry, ToolConnection, ToolPolicy, } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { queryKeys } from "../lib/queryKeys"; import { toolsApi } from "../api/tools"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { GithubIcon } from "@/components/icons/github-icon"; import { InlineBanner } from "@/components/InlineBanner"; import { EnforcementBanner } from "../components/EnforcementBanner"; import { RiskBadge, CapabilityBadges, LoadingState as ToolsLoadingState, ErrorState as ToolsErrorState, } from "./tools/shared"; import { cn } from "../lib/utils"; import { brandChipBadge } from "../lib/status-colors"; import { installPayload, installStateFrom, isAgentInstalled, INSTALLED_HINT } from "../lib/tool-installs"; function isGitHubConnection(connection: ToolConnection): boolean { return (connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey) === "github"; } function isEligibleGitHubIdentityConnection(connection: ToolConnection, agentId: string): boolean { return isGitHubConnection(connection) && connection.enabled && connection.status === "active" && isAgentInstalled(installStateFrom(connection.installs), agentId); } function GitHubIdentitySection({ agentName, dedicatedIdentity, personalIdentity, loading, loadError, onRetry, }: { agentName: string; dedicatedIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null; personalIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null; loading: boolean; loadError: boolean; onRetry: () => void; }) { const dedicatedLogin = dedicatedIdentity?.grant.providerTenant?.github?.login; const personalLogin = personalIdentity?.grant.providerTenant?.github?.login; return (

GitHub identity

{loading ? (

Checking GitHub identity…

) : loadError ? ( <>

Could not load GitHub identity

Paperclip could not verify this agent's current connection. Your existing setup was not changed.

) : dedicatedIdentity ? ( <>

{dedicatedLogin ? `@${dedicatedLogin}` : "Dedicated GitHub account"}

Used only by {agentName}. This dedicated connection takes precedence over the responsible person's GitHub.

) : ( <>

Use responsible person's GitHub

At run start, Paperclip uses the personal GitHub connection of the person responsible for the task.

{personalIdentity ? (

Your account is connected{personalLogin ? ` as @${personalLogin}` : ""}.

) : null} )}
{!loading ? (
{loadError ? ( ) : dedicatedIdentity ? ( ) : ( <> {personalIdentity ? ( ) : ( )} )}
) : null}
); } /** Normalize a selector value (string or string[]) into a flat string list. */ function selectorStringList(value: unknown): string[] { if (typeof value === "string") return value.trim() ? [value.trim()] : []; if (Array.isArray(value)) { return value.filter((v): v is string => typeof v === "string" && v.trim().length > 0); } return []; } /** * A policy governs this agent's allow list when it either explicitly names the * agent, or carries no agent/actor restriction that would exclude agents. This * mirrors how the tool gateway evaluates selectors server-side. */ export function policyGovernsAgent(policy: ToolPolicy, agentId: string): boolean { const selectors = (policy.selectors ?? {}) as Record; const agentIds = [ ...selectorStringList(selectors.agentId), ...selectorStringList(selectors.agentIds), ]; if (agentIds.length > 0) return agentIds.includes(agentId); const actorTypes = [ ...selectorStringList(selectors.actorType), ...selectorStringList(selectors.actorTypes), ]; if (actorTypes.length > 0 && !actorTypes.includes("agent")) return false; return true; } export function mergeInstallDraft( serverDraft: Record, currentDraft: Record, lastSavedDraft: Record, ): { draft: Record; hasPendingChanges: boolean } { const draft = { ...serverDraft }; let hasPendingChanges = false; for (const [connectionId, installed] of Object.entries(currentDraft)) { if (installed === (lastSavedDraft[connectionId] ?? false)) continue; draft[connectionId] = installed; hasPendingChanges = true; } return { draft, hasPendingChanges }; } function InstalledAppsSection({ agentId, agentName, connections, draft, permittedConnectionIds, pendingConnectionId, saving, unsaved, error, onChange, }: { agentId: string; agentName: string; connections: ToolConnection[]; draft: Record; permittedConnectionIds: Set; pendingConnectionId: string | null; saving: boolean; unsaved: boolean; error: boolean; onChange: (connectionId: string, installed: boolean) => void; }) { return (

Installed apps

Installed apps load tools into {agentName}'s context on every run. Permitted-only apps do not add context cost.

Has access means the app is permitted. Installed means its tools are added to this agent's runtime context. {connections.length === 0 ? (

No permitted apps yet. Bind an access profile to make apps available here.

) : (
{connections.map((connection) => { const installState = installStateFrom(connection.installs); const installedForAll = installState.onAll; const checked = installedForAll || (draft[connection.id] ?? installState.agentIds.has(agentId)); const permitted = permittedConnectionIds.has(connection.id); const rowPending = pendingConnectionId === connection.id; return (
{permitted && !checked ? ( Open permissions )} > Permitted but not installed — tools will not appear in runs. ) : null}
); })}
)}
); } function InstallBadge({ installed, installedForAll, permitted, }: { installed: boolean; installedForAll: boolean; permitted: boolean; }) { const label = installed ? (installedForAll ? "Installed for all" : "Installed") : permitted ? "Permitted only" : "Not permitted"; return ( {installed ? : null} {label} ); } function InstallSaveStatusChip({ pending, unsaved, error, }: { pending: boolean; unsaved: boolean; error: boolean; }) { if (pending) return Saving...; if (error) return Could not save; if (unsaved) return Unsaved changes; return Saved; } const POLICY_EFFECT_LABEL: Record = { allow: "allow", block: "block", deny: "deny", require_approval: "require approval", redact: "redact", rate_limit: "rate limit", }; const DENIED_TOOLS_DISPLAY_LIMIT = 30; /** * Agent detail · Tools tab (PAP-10788, surface 09 of the PAP-10771 v2 spec). * * Communicates the agent's resolved tool access: the banner makes clear the * prompt can narrow the list but never expand it, and the side panel explains * which access profiles and rules shape the final list. */ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord; companyId: string }) { const queryClient = useQueryClient(); const [installDraft, setInstallDraft] = useState>({}); const lastSavedInstallRef = useRef>({}); const skipNextInstallAutosaveRef = useRef(true); const failedInstallDraftRef = useRef<{ connectionId: string; installed: boolean } | null>(null); const effective = useQuery({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id), queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agent.id), }); const connectionsQuery = useQuery({ queryKey: queryKeys.tools.connections(companyId), queryFn: () => toolsApi.listConnections(companyId), }); const policiesQuery = useQuery({ queryKey: queryKeys.tools.policies(companyId), queryFn: () => toolsApi.listPolicies(companyId), }); const connectionList = connectionsQuery.data?.connections ?? []; const eligibleGitHubConnections = useMemo( () => connectionList.filter((connection) => isEligibleGitHubIdentityConnection(connection, agent.id)), [agent.id, connectionList], ); const githubGrantQueries = useQueries({ queries: eligibleGitHubConnections.map((connection) => ({ queryKey: queryKeys.tools.connectionGrants(connection.id), queryFn: () => toolsApi.listConnectionGrants(connection.id), staleTime: 30_000, })), }); const githubIdentityRows = eligibleGitHubConnections.flatMap((connection, index) => (githubGrantQueries[index]?.data?.grants ?? []).map((grant) => ({ connection, grant, currentUserId: githubGrantQueries[index]?.data?.currentUserId, })), ); const dedicatedGitHubIdentity = githubIdentityRows.find(({ grant }) => ( grant.kind === "agent" && grant.subjectAgentId === agent.id && grant.status === "active" )) ?? null; const personalGitHubIdentity = githubIdentityRows.find(({ grant, currentUserId }) => ( grant.kind === "user" && grant.subjectUserId === currentUserId && grant.status === "active" )) ?? null; const connectionInstallSignature = useMemo( () => connectionList .map((connection) => { const installKey = (connection.installs ?? []) .map((install) => `${install.targetType}:${install.targetId}`) .sort() .join(","); return `${connection.id}=${installKey}`; }) .join("|"), [connectionList], ); const syncInstall = useMutation({ mutationFn: ({ connection, installed }: { connection: ToolConnection; installed: boolean }) => { const nextState = installStateFrom(connection.installs); if (!nextState.onAll) { if (installed) nextState.agentIds.add(agent.id); else nextState.agentIds.delete(agent.id); } return toolsApi.putConnectionInstalls( connection.id, installPayload(connection.companyId ?? companyId, nextState), ); }, onSuccess: async (_snapshot, variables) => { failedInstallDraftRef.current = null; lastSavedInstallRef.current = { ...lastSavedInstallRef.current, [variables.connection.id]: variables.installed, }; await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }), queryClient.invalidateQueries({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id) }), queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(variables.connection.id) }), ]); }, onError: (_error, variables) => { failedInstallDraftRef.current = { connectionId: variables.connection.id, installed: variables.installed, }; }, }); useEffect(() => { setInstallDraft({}); lastSavedInstallRef.current = {}; skipNextInstallAutosaveRef.current = true; failedInstallDraftRef.current = null; }, [agent.id, companyId]); useEffect(() => { const next = Object.fromEntries( connectionList.map((connection) => [ connection.id, isAgentInstalled(installStateFrom(connection.installs), agent.id), ]), ); failedInstallDraftRef.current = null; setInstallDraft((current) => { const merged = mergeInstallDraft(next, current, lastSavedInstallRef.current); lastSavedInstallRef.current = next; skipNextInstallAutosaveRef.current = !merged.hasPendingChanges; return merged.draft; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [agent.id, connectionInstallSignature]); useEffect(() => { if (skipNextInstallAutosaveRef.current) { skipNextInstallAutosaveRef.current = false; return; } if (syncInstall.isPending) return; const changedConnection = connectionList.find((connection) => { const saved = lastSavedInstallRef.current[connection.id] ?? false; const draft = installDraft[connection.id] ?? false; const failed = failedInstallDraftRef.current; return saved !== draft && !(failed?.connectionId === connection.id && failed.installed === draft); }); if (!changedConnection) return; const timeout = window.setTimeout(() => { const installed = installDraft[changedConnection.id] ?? false; const saved = lastSavedInstallRef.current[changedConnection.id] ?? false; const failed = failedInstallDraftRef.current; if (saved !== installed && !(failed?.connectionId === changedConnection.id && failed.installed === installed)) { syncInstall.mutate({ connection: changedConnection, installed }); } }, 250); return () => window.clearTimeout(timeout); }, [connectionList, installDraft, syncInstall.isPending, syncInstall.mutate]); // The effective endpoint returns the *allowed* slice of the catalog. To show // "Denied tools (suppressed)" we need the full company catalog, which is only // exposed per-connection — same aggregation the Applications tab uses. const catalogQueries = useQueries({ queries: connectionList.map((connection) => ({ queryKey: queryKeys.tools.catalog(connection.id), queryFn: () => toolsApi.listCatalog(connection.id), staleTime: 60_000, })), }); const connectionNameById = useMemo(() => { const map = new Map(); for (const connection of connectionList) map.set(connection.id, connection.name); return map; }, [connectionList]); const allowedTools = useMemo( () => [...(effective.data?.allowedTools ?? [])].sort((a, b) => a.toolName.localeCompare(b.toolName), ), [effective.data?.allowedTools], ); const permittedConnectionIds = useMemo( () => new Set([ ...(effective.data?.entries ?? []) .filter((entry) => entry.effect === "include" && entry.connectionId) .map((entry) => entry.connectionId!), ...allowedTools.map((tool) => tool.connectionId), ]), [allowedTools, effective.data?.entries], ); const installedAppConnections = useMemo( () => connectionList .filter((connection) => permittedConnectionIds.has(connection.id) || (installDraft[connection.id] ?? false)) .sort((a, b) => a.name.localeCompare(b.name)), [connectionList, installDraft, permittedConnectionIds], ); const hasInstallUnsavedChanges = installedAppConnections.some( (connection) => (installDraft[connection.id] ?? false) !== (lastSavedInstallRef.current[connection.id] ?? false), ); const catalogStamp = catalogQueries.map((q) => q.dataUpdatedAt).join(","); const deniedTools = useMemo(() => { const allowedIds = new Set((effective.data?.allowedTools ?? []).map((tool) => tool.id)); const seen = new Set(); const denied: ToolCatalogEntry[] = []; for (const result of catalogQueries) { for (const entry of result.data?.catalog ?? []) { if (entry.status !== "active") continue; if (allowedIds.has(entry.id) || seen.has(entry.id)) continue; seen.add(entry.id); denied.push(entry); } } return denied.sort((a, b) => a.toolName.localeCompare(b.toolName)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [effective.data?.allowedTools, catalogStamp]); const governingPolicies = useMemo(() => { const policies = policiesQuery.data?.policies ?? []; return policies .map((policy, index) => ({ policy, order: index + 1 })) .filter(({ policy }) => policy.enabled && policyGovernsAgent(policy, agent.id)); }, [policiesQuery.data?.policies, agent.id]); const profiles = effective.data?.profiles ?? []; const catalogLoading = catalogQueries.some((q) => q.isLoading); if (effective.isLoading) return ; if (effective.error) { return effective.refetch()} />; } const profilesHref = "/apps/advanced/profiles"; return (
This is exactly the tool set Paperclip will accept for{" "} {agent.name}. Profile and policy edits are reflected within ~5 seconds. The agent's prompt can narrow this list but{" "} cannot expand it — everything else is blocked by default. } /> query.isLoading)} loadError={connectionsQuery.isError || githubGrantQueries.some((query) => query.isError)} onRetry={() => { if (connectionsQuery.isError) void connectionsQuery.refetch(); for (const query of githubGrantQueries) { if (query.isError) void query.refetch(); } }} /> { failedInstallDraftRef.current = null; setInstallDraft((current) => ({ ...current, [connectionId]: installed })); }} />
{/* Allowed tools table */}

Allowed tools

{allowedTools.length} {allowedTools.length === 1 ? "tool" : "tools"}
{allowedTools.length === 0 ? (

No tools are allowed for this agent. Bind a tool profile to grant access.

) : ( {allowedTools.map((tool) => ( ))}
Tool Capability Risk Source
{tool.toolName}
{tool.title ? (
{tool.title}
) : null}
{connectionNameById.get(tool.connectionId) ?? "—"}
)}
{/* Why these tools? side panel */}
); }