Files
PaperClipAI/ui/src/pages/AgentToolsTab.tsx
T
a3313f6944 fix(ui): complete read recovery and enforce query error rendering (#18822)
## Thinking Path

> - Paperclip lets people manage agent work through the board.
> - The board reads task, agent, settings, plugin, and run data from the
server.
> - A deploy can interrupt those reads after content is already visible.
> - Local error branches can replace that content with an error or show
raw codes.
> - This pull request moves the remaining reads to the shared recovery
behavior.
> - The query-error guard prevents new local error patterns from
returning.

## Linked Issues or Issue Description

**What happened?**
Loaded lists, settings, task panels, and manual reads could clear their
content or show errors during a server restart. Some reads also disabled
the shared retry policy.

**Expected behavior**
Keep loaded content during a temporary failure. Show the shared
connection banner. Retry reads after recovery. Replace content after a
real access denial or missing resource, with readable copy.

**Steps to reproduce**
Open a task, settings page, or run log. Return 503 with
`tenant_app_unavailable` from the API. Restore the API and check that
content refreshes. Repeat with 403 and 404 to check the failure state.

Related merged work: Refs #15754, Refs #15772, Refs #17960. This change
covers remaining reads and enforcement. Browser persistence and the
outage browser suite follow separately.

## What Changed

- Repair an existing PostgreSQL deadline fixture at UTC boundaries. A
daily cap defers a heartbeat until both its original interval and the
next UTC day allow it. Keep the counted run in its seeded day and cover
noon, just before midnight and the first second after midnight with
explicit clocks and real PostgreSQL. Production scheduling is unchanged.

- Integrate the saved list, settings, shell, task, and manual-read
recovery changes against current master. Preserve the current task,
Inbox, and run-log structure.
- Add `useResilientFetch` for component-local reads. Preserve loaded
data, cancel stale requests, and retry through the shared connectivity
policy. Keep browser and pairing capabilities in memory.
- Retain run log chunks across stream changes and catch up after
recovery without duplicate chunks or stalled requests.
- Keep genuine authorization and missing-resource failures visible.
Translate known single-word codes such as `forbidden`. Show known skills
and disconnected-app failures before sibling loading states. Keep Retry
bound to its failed request.
- Make the query-error guard enforce by default and run it in the CI
policy job. Document the narrow exceptions and the read recovery
contract.
- Invalidate pending OAuth discovery when the operator leaves setup or
its context changes. Keep reserved-popup cleanup bound to the current
attempt.

## Verification

- Current read head `09d1db29dea` is merge-ready: all 59 check runs are
completed (54 successful, three skipped, two neutral), the legacy Snyk
status is successful, and both required `ci / verify` and `ci / e2e`
gates pass. Greptile Apex reviewed this exact head at 5/5 with no
unresolved threads; the branch has no merge conflicts. The current CI
run is `41748339602`.
- Current read head `09d1db29dea` includes master `683fa4725e9`. The
latest change touches only the existing server deadline test fixture.
Application source remains the previously verified `86376fa751e`
revision: all 60 affected skills/disconnected-app tests and both
application UI typechecks passed, along with token gates and the
enforcing query-error guard. Those UI suites were not repeated for this
test-only repair.
- The fixture repair passes all seven deadline integration cases and the
complete server typecheck, including the server test layer. The old
assertion deterministically fails at 23:59:06.888 UTC; its
one-second-old run also fails the first-second-of-day probe. The
corrected fixture passes noon and both midnight probes. The permanent
three-case coverage uses seeded 2099 clocks.
- Twelve new regressions fail against the previous implementation. Both
skills variants and disconnected-app pages show either request's 403 or
429 while the sibling pauses or remains fetching. Permission failures
stay visible. Retry stays usable for its failed request and recovers
independently. An initial test-fixture query-key typo was corrected
before the passing run. The finding is replied to and resolved. Greptile
reviewed prior application revision `86376fa751e` at 5/5 with no new
actionable findings or unresolved threads. The current test-only head
has now received its own Apex 5/5 review with no unresolved threads, and
its CI is successful as recorded above.
- The preceding OAuth fix passed all 237 setup tests on both application
branches. Its ten regressions prove that Back, Cancel, unmount, or
context changes retire initial discovery without creating a connection,
caching old identities, navigating an abandoned popup, or closing a
newer attempt.
- Earlier master integration `e992f770339` passed all 309 tests across
eight affected Slack, recovery, retry, submission and task files, plus
UI typecheck, token gates, enforcing query-error scan and seven guard
tests. The integration preserves Slack workspace reconnect and shared
recovery, readable execution errors, source-inspection company scoping,
and current submission behavior. Newly inherited retained-file recovery
controls keep cached data through transient failures and hide it after
definitive denial.
- The preceding cache head `97c0bc3966f` passed 1,038 focused
cache/write/editor/account/task/API tests. All 22 built-UI
outage/write/loading cases passed in 4.7 minutes on exact browser head
`b7deaaee5da`, with strict checking of all three specs. The latest
skills/app rendering fix is covered by its affected component suites;
those broader cases were not repeated for this separate read path.
- Historical broader UI, CLI and build results and unresolved aggregate
server/DB/adapter failures remain in the PR discussion. Interrupted CI
runs were audited and recorded separately; they are not counted as
passes. Controlled local outages do not prove hosted authentication or a
real Cloud restart/deploy. Real hosted acceptance remains required after
merge and deployment.

Commands for the latest fix:

```sh
pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx
pnpm --filter @paperclipai/ui typecheck
pnpm check:token-gates
node scripts/check-query-error-rendering.mjs --enforce --summary
```

## Risks

This migration touches many read surfaces. The main risks are initial
loading, request cancellation, retained authorization state, and run-log
paging. The tests cover those states. There are no server, schema,
dependency, or API contract changes. The follow-up browser suite and
hosted deploy acceptance are required before release acceptance.

Recovered commits retain their original authors. If squash-merging,
preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>`
and the Paperclip trailer.

## Model Used

OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool
use, and code execution. The context window size is not exposed. Saved
source commits keep their original authorship.

## 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 described the issue in-PR following the bug report template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] The affected local tests pass; broader limitations are recorded
above
- [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 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: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 14:07:18 -08:00

786 lines
32 KiB
TypeScript

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 (
<section className="rounded-lg border border-border bg-card">
<div className="flex flex-wrap items-start justify-between gap-3 px-3 py-3">
<div className="flex min-w-0 items-start gap-3">
<div className="rounded-md border border-border bg-muted/40 p-2 text-foreground">
<GithubIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">GitHub identity</h3>
{loading ? (
<p className="mt-0.5 text-xs text-muted-foreground">Checking GitHub identity…</p>
) : loadError ? (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">Could not load GitHub identity</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Paperclip could not verify this agent&apos;s current connection. Your existing setup was not changed.
</p>
</>
) : dedicatedIdentity ? (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">
{dedicatedLogin ? `@${dedicatedLogin}` : "Dedicated GitHub account"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Used only by {agentName}. This dedicated connection takes precedence over the responsible person&apos;s GitHub.
</p>
</>
) : (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">Use responsible person&apos;s GitHub</p>
<p className="mt-0.5 text-xs text-muted-foreground">
At run start, Paperclip uses the personal GitHub connection of the person responsible for the task.
</p>
{personalIdentity ? (
<p className="mt-1 text-xs text-muted-foreground">
Your account is connected{personalLogin ? ` as @${personalLogin}` : ""}.
</p>
) : null}
</>
)}
</div>
</div>
{!loading ? (
<div className="flex flex-wrap items-center gap-2">
{loadError ? (
<Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>
) : dedicatedIdentity ? (
<Button variant="outline" size="sm" asChild>
<Link to={`/apps/${dedicatedIdentity.connection.id}/permissions`}>Manage GitHub identity</Link>
</Button>
) : (
<>
{personalIdentity ? (
<Button variant="outline" size="sm" asChild>
<Link to={`/apps/${personalIdentity.connection.id}/permissions`}>Manage my GitHub</Link>
</Button>
) : (
<Button size="sm" asChild>
<Link to="/apps/connect?source=github">Connect my GitHub</Link>
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to="/apps/connect?source=github">Use a dedicated account</Link>
</Button>
</>
)}
</div>
) : null}
</div>
</section>
);
}
/** 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<string, unknown>;
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<string, boolean>,
currentDraft: Record<string, boolean>,
lastSavedDraft: Record<string, boolean>,
): { draft: Record<string, boolean>; 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<string, boolean>;
permittedConnectionIds: Set<string>;
pendingConnectionId: string | null;
saving: boolean;
unsaved: boolean;
error: boolean;
onChange: (connectionId: string, installed: boolean) => void;
}) {
return (
<section className="rounded-lg border border-border bg-card">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-3 py-2.5">
<div>
<h3 className="text-sm font-semibold text-foreground">Installed apps</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
Installed apps load tools into {agentName}'s context on every run. Permitted-only apps do not add context cost.
</p>
</div>
<InstallSaveStatusChip pending={saving} unsaved={unsaved} error={error} />
</div>
<div className="space-y-3 p-3">
<InlineBanner tone="info" compact>
Has access means the app is permitted. Installed means its tools are added to this agent's runtime context.
</InlineBanner>
{connections.length === 0 ? (
<p className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
No permitted apps yet. Bind an access profile to make apps available here.
</p>
) : (
<div className="divide-y divide-border rounded-md border border-border">
{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 (
<div key={connection.id} className="space-y-2 px-3 py-3">
<label className="flex items-start gap-3">
<Checkbox
checked={checked}
disabled={installedForAll || rowPending}
aria-label={`Install ${connection.name} on ${agentName}`}
onCheckedChange={(next) => onChange(connection.id, Boolean(next))}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{connection.name}</span>
<InstallBadge installed={checked} installedForAll={installedForAll} permitted={permitted} />
{rowPending ? <span className="text-xs text-muted-foreground">Saving...</span> : null}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{installedForAll
? "Installed from the app page for every agent. Remove the all-agents install there."
: checked
? "Loaded into this agent's runtime context."
: INSTALLED_HINT}
</span>
</span>
</label>
{permitted && !checked ? (
<InlineBanner
tone="warning"
compact
className="ml-7"
actions={(
<Link
to={`/apps/${connection.id}/permissions`}
className="text-xs font-medium text-primary hover:underline"
>
Open permissions
</Link>
)}
>
Permitted but not installed — tools will not appear in runs.
</InlineBanner>
) : null}
</div>
);
})}
</div>
)}
</div>
</section>
);
}
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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
installed ? brandChipBadge.green : brandChipBadge.gray,
)}
>
{installed ? <PackageCheck className="h-3 w-3" /> : null}
{label}
</span>
);
}
function InstallSaveStatusChip({
pending,
unsaved,
error,
}: {
pending: boolean;
unsaved: boolean;
error: boolean;
}) {
if (pending) return <span className="text-xs text-muted-foreground">Saving...</span>;
if (error) return <span className="text-xs text-destructive">Could not save</span>;
if (unsaved) return <span className="text-xs text-muted-foreground">Unsaved changes</span>;
return <span className="text-xs text-muted-foreground">Saved</span>;
}
const POLICY_EFFECT_LABEL: Record<string, string> = {
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<Record<string, boolean>>({});
const lastSavedInstallRef = useRef<Record<string, boolean>>({});
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<string, string>();
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<string>();
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 <ToolsLoadingState label="Resolving effective access…" />;
if (effective.error) {
return <ToolsErrorState error={effective.error} onRetry={() => effective.refetch()} />;
}
const profilesHref = "/apps/advanced/profiles";
return (
<div className="space-y-4">
<EnforcementBanner
tone="info"
title="Effective access"
body={
<>
This is exactly the tool set Paperclip will accept for{" "}
<span className="font-medium">{agent.name}</span>. Profile and policy edits are
reflected within ~5 seconds. The agent's prompt can narrow this list but{" "}
<span className="font-medium">cannot expand it</span> — everything else is blocked by
default.
</>
}
/>
<GitHubIdentitySection
agentName={agent.name}
dedicatedIdentity={dedicatedGitHubIdentity}
personalIdentity={personalGitHubIdentity}
loading={connectionsQuery.isLoading || githubGrantQueries.some((query) => 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();
}
}}
/>
<InstalledAppsSection
agentId={agent.id}
agentName={agent.name}
connections={installedAppConnections}
draft={installDraft}
permittedConnectionIds={permittedConnectionIds}
pendingConnectionId={syncInstall.isPending ? syncInstall.variables?.connection.id ?? null : null}
saving={syncInstall.isPending}
unsaved={hasInstallUnsavedChanges}
error={syncInstall.isError && hasInstallUnsavedChanges} // query-error-ok: syncInstall is a save mutation result, not a query read
onChange={(connectionId, installed) => {
failedInstallDraftRef.current = null;
setInstallDraft((current) => ({ ...current, [connectionId]: installed }));
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Allowed tools table */}
<div className="lg:col-span-2">
<div className="rounded-lg border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Allowed tools</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{allowedTools.length} {allowedTools.length === 1 ? "tool" : "tools"}
</span>
</div>
{allowedTools.length === 0 ? (
<p className="px-3 py-6 text-sm text-muted-foreground">
No tools are allowed for this agent. Bind a tool profile to grant access.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-3 py-2 font-medium">Tool</th>
<th className="px-3 py-2 font-medium">Capability</th>
<th className="px-3 py-2 font-medium">Risk</th>
<th className="px-3 py-2 font-medium">Source</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{allowedTools.map((tool) => (
<tr key={tool.id} className="align-top">
<td className="px-3 py-2">
<div className="font-mono text-xs text-foreground">{tool.toolName}</div>
{tool.title ? (
<div className="text-(length:--text-micro) text-muted-foreground">{tool.title}</div>
) : null}
</td>
<td className="px-3 py-2">
<CapabilityBadges
isReadOnly={tool.isReadOnly}
isWrite={tool.isWrite}
isDestructive={tool.isDestructive}
/>
</td>
<td className="px-3 py-2">
<RiskBadge risk={tool.riskLevel} />
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{connectionNameById.get(tool.connectionId) ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Why these tools? side panel */}
<aside className="space-y-4">
<div className="rounded-lg border border-border bg-background/60 p-3">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
Why these tools?
</h3>
{/* Access profiles */}
<div className="mt-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Access profiles
</div>
<Link
to={`${profilesHref}?check=1`}
className="text-(length:--text-micro) font-medium text-primary hover:underline"
>
Check access
</Link>
</div>
{profiles.length === 0 ? (
<p className="text-xs text-muted-foreground">
No active profile applies to this agent, so it has no allowed tools.
</p>
) : (
profiles.map((profile) => {
return (
<div key={profile.id} className="rounded-md border border-border/70 px-2.5 py-2">
<Link
to={`${profilesHref}/${profile.id}`}
className="text-xs font-medium text-primary hover:underline"
>
{profile.name}
</Link>
{profile.summary.isCompanyDefault ? (
<div className="mt-1">
<span className="rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
Organization default
</span>
</div>
) : null}
</div>
);
})
)}
</div>
{/* Policies mutating the allow list */}
<div className="mt-3 space-y-1.5">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Active policies
</div>
{policiesQuery.isLoading ? (
<p className="text-xs text-muted-foreground">Loading policies…</p>
) : governingPolicies.length === 0 ? (
<p className="text-xs text-muted-foreground">
No enabled policy currently mutates this agent's allow list.
</p>
) : (
governingPolicies.map(({ policy, order }) => (
<div key={policy.id} className="rounded-md border border-border/70 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span
className="truncate text-xs font-medium text-foreground"
title={`Policy #${order}: ${policy.name}`}
>
#{order} {policy.name}
</span>
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
{POLICY_EFFECT_LABEL[policy.policyType] ?? policy.policyType}
</span>
</div>
{policy.description ? (
<div className="mt-0.5 truncate text-(length:--text-micro) text-muted-foreground">
{policy.description}
</div>
) : null}
</div>
))
)}
</div>
{/* Unavailable tools */}
<div className="mt-3 space-y-1.5">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Unavailable tools
</div>
{catalogLoading ? (
<p className="text-xs text-muted-foreground">Checking tools…</p>
) : deniedTools.length === 0 ? (
<p className="text-xs text-muted-foreground">
Every known tool this agent could name is allowed.
</p>
) : (
<>
<p className="text-(length:--text-micro) text-muted-foreground">
Tools the agent could name but Paperclip would block:
</p>
<div className="flex flex-wrap gap-1">
{deniedTools.slice(0, DENIED_TOOLS_DISPLAY_LIMIT).map((tool) => (
<span
key={tool.id}
className="inline-flex items-center gap-1 rounded border border-border/70 bg-muted/40 px-1.5 py-0.5 font-mono text-(length:--text-micro) text-muted-foreground"
title={connectionNameById.get(tool.connectionId) ?? undefined}
>
{tool.toolName}
</span>
))}
</div>
{deniedTools.length > DENIED_TOOLS_DISPLAY_LIMIT ? (
<p className="text-(length:--text-micro) text-muted-foreground">
+{deniedTools.length - DENIED_TOOLS_DISPLAY_LIMIT} more
</p>
) : null}
</>
)}
</div>
</div>
</aside>
</div>
</div>
);
}