Files
PaperClipAI/ui/src/components/AppConnectionSidebar.tsx
T
DottaandPaperclip d23fbf8ae4 feat(connections): add AppDefinition Wave 1 catalog (#9981)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Connections is the subsystem that defines which external apps and
MCP-style integrations operators can browse, configure, and run
> - The v3 schema core in #9958 added stable connection identities, auth
metadata, and grant-aware contracts, but the app catalog still used the
older gallery shape
> - The product needs a richer, typed AppDefinition catalog so browsing
and setup can render provider-specific auth and configuration
requirements consistently
> - This pull request moves the Wave 1 app catalog onto generated
AppDefinition data and carries that shape through shared types, server
lookup paths, and app connection UI
> - The benefit is that follow-up runtime and wizard work can build
against one catalog contract instead of local-only mock/gallery data

## Linked Issues or Issue Description

Refs #9958.

No public GitHub issue exists for this branch. This is the catalog layer
for the Connections v3 stack after the schema-core foundation in #9958.

## What Changed

- Adds generated AppDefinition data for the Wave 1 catalog and ingestion
reporting.
- Replaces the legacy tool app gallery exports with
AppDefinition-centered shared contracts, validators, and tests.
- Updates server tool-access lookup behavior to use the AppDefinition
catalog.
- Updates app connection UI surfaces and tests to consume
AppDefinition-backed catalog data.
- Documents the catalog ingestion workflow in the connector playbook.

## Verification

- `pnpm run preflight:workspace-links`
- `pnpm exec vitest run packages/shared/src/app-definitions.test.ts
packages/shared/src/app-definitions-url.test.ts
ui/src/pages/apps/AppsConnect.test.tsx
server/src/__tests__/tool-access-service.test.ts`

## Risks

- Medium: this changes the catalog contract used by shared, server, and
UI app connection surfaces.
- Catalog data quality matters because generated definitions now drive
browse/setup display.
- Follow-up runtime and wizard PRs must rebase on this branch or on
master after this lands.

> 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 GPT-5 Codex coding agent with repository tool use.

## 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>
2026-07-21 15:57:12 -05:00

154 lines
6.2 KiB
TypeScript

import { ChevronLeft } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { toolsApi } from "@/api/tools";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { queryKeys } from "@/lib/queryKeys";
import {
APP_TABS,
CONNECTED_ONLY_APP_TABS,
appApplicationTabHref,
appTabHref,
type AppTabKey,
} from "@/pages/apps/app-tabs";
import { AppLogo } from "@/pages/apps/AppLogo";
import {
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
type AppGalleryDisplayEntry,
} from "@/pages/apps/app-definition-display";
import { SidebarNavItem } from "./SidebarNavItem";
type AppDetailSidebarProps =
| { kind: "connection"; connectionId: string }
| { kind: "application"; applicationId: string };
export function AppDetailSidebar(props: AppDetailSidebarProps) {
const { selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const connectionQuery = useQuery({
queryKey: queryKeys.tools.connection(props.kind === "connection" ? props.connectionId : "__none__"),
queryFn: () => toolsApi.getConnection(props.kind === "connection" ? props.connectionId : ""),
enabled: props.kind === "connection" && !!props.connectionId,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: props.kind === "application" && !!selectedCompanyId,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: props.kind === "application" && !!selectedCompanyId,
});
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const attentionQuery = useQuery({
queryKey: queryKeys.apps.attention(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listAppsAttention(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 30_000,
});
const connection = connectionQuery.data;
const application = props.kind === "application"
? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId)
: null;
const appConnections = props.kind === "application"
? (connectionsQuery.data?.connections ?? []).filter((candidate) => candidate.applicationId === props.applicationId)
: [];
const previousConnection = latestArchivedConnection(appConnections);
const appName = connection ? humanizeConnectionDisplayName(connection) : application?.name ?? "App";
const logoEntry = galleryEntryFor(
(galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[],
connection,
application ?? undefined,
);
const reviewConnectionId = connection?.id ?? previousConnection?.id ?? null;
const attentionItem = reviewConnectionId
? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId)
: null;
const reviewCount =
(attentionItem?.pendingActionRequestCount ?? 0) + (attentionItem?.quarantinedCatalogEntryCount ?? 0);
return (
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
<div className="flex shrink-0 flex-col gap-3 px-3 py-3">
<Link
to="/apps"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">All apps</span>
</Link>
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(logoEntry)} size={28} />
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
</div>
</div>
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.filter(
(tab) => props.kind === "connection" || !CONNECTED_ONLY_APP_TABS.has(tab.key),
).map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
label={tab.label}
icon={tab.icon}
end
badge={tab.key === "review" && reviewCount > 0 ? reviewCount : undefined}
badgeTone="danger"
badgeLabel="needing review"
/>
))}
</div>
</nav>
</aside>
);
}
function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string {
return props.kind === "connection"
? appTabHref(props.connectionId, tab)
: appApplicationTabHref(props.applicationId, tab);
}
function galleryEntryFor(
apps: AppGalleryDisplayEntry[],
connection: ToolConnection | undefined,
application: ToolApplication | undefined,
): AppGalleryDisplayEntry | null {
if (application?.applicationKey) {
const keyed = apps.find((app) => appDefinitionSlug(app) === application.applicationKey);
if (keyed) return keyed;
}
const name = (connection?.name ?? application?.name)?.toLowerCase();
if (!name) return null;
return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ??
apps.find((app) => appDefinitionSlug(app) === name) ??
null;
}
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
const archived = connections.filter((connection) => connection.status === "archived");
if (archived.length === 0) return null;
return archived.reduce((latest, connection) => {
const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime();
const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime();
return connectionTime > latestTime ? connection : latest;
});
}