Files
PaperClipAI/ui/src/pages/apps/gateways/GatewaysList.tsx
T
DottaandClaude Opus 5.5 b184e40108 perf(ui): serve every project list from one includeArchived read (#20395)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI reads the company project list in many places: the
sidebar, starred projects, the command palette, the new-task dialog, and
archive-aware pages such as the Mine inbox
> - Active-only consumers read `GET /projects`, and archive-aware ones
read `GET /projects?includeArchived=true`, under two React Query keys
> - A Mine load therefore fetched the same large payload twice (580–590
KB each on our dev instance)
> - The two responses differ only by archived rows; the server's active
list is exactly the rows where `archivedAt` is null
> - This pull request makes every consumer share the
`includeArchived=true` query, and active-only consumers filter it with
`select`
> - The benefit is one project read per page instead of two

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Duplicate project list reads on page load.

**Subsystem affected**
Board UI project queries (`ui/src/lib/company-projects-query.ts` and its
consumers).

**Current behavior**
A cold Mine load sends both `/companies/:id/projects` (sidebar) and
`/companies/:id/projects?includeArchived=true` (inbox).

**Proposed behavior**
One `includeArchived=true` read serves both. Active-only consumers apply
`selectActiveProjects`.

**Reason and benefit**
The project list is one of the largest shell payloads. Reading it once
saves a large response on every page that has both kinds of consumer.

## What Changed

- New `companyProjectsQuery(companyId, { includeArchived? })` in
`ui/src/lib/company-projects-query.ts`. It always uses the
`includeArchived: true` key and fetch. For active-only use it adds a
stable, module-level `select` that keeps rows without `archivedAt`.
- 22 files that read the active list now spread
`companyProjectsQuery(...)`, and so do the plugin SDK project picker and
16 archive-aware pages that wrote the shared key and fetcher inline.
Their `enabled` and other options are unchanged. `OnboardingWizard`'s
one imperative read is unchanged. `pages/Inbox.tsx` already reads the
same key and fetcher, so it shares the entry without an edit.
- Invalidation: project mutations and live events invalidate
`queryKeys.projects.all(companyId)`, which matches the shared key. The
onboarding wizard invalidated the old active-only key; it now
invalidates `projects.all`.
- Tests: new `company-projects-query.test.tsx` (one request for both
consumers, correct filtering, and one refetch after `projects.all`
invalidation). Three existing tests (`CommandPalette`, `CompanyExport`,
`GitHubBotManagement`) now seed or expect the shared key.
- New browser check
`tests/perf/collections/shared-project-list.spec.ts`: after the Mine
inbox read completes, the page has sent no active-only project request.

## Verification

- `pnpm verify` passes.
- `ui`: the consumer suites (`CoordinatorSidebar`, `SidebarProjects`,
`SidebarStarredProjects`, `CommandPalette`, `NewIssueDialog`,
`AgentWorkPanels`, `CoordinatorNeedsYou`, `IssueDetail`,
`ProjectDetail`, `Projects`, `CompanyExport`, `ChatExecutionDefaults`,
`GatewaysList`, `Search`, `IssueProperties`, `persisted-query-cache`)
pass.
- Browser check:
`PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
shared-project-list.spec.ts`. It passes on this branch and fails on
`master`.

## Risks

- A page that needs only active projects now downloads archived rows
too. For most companies that is a small difference; the duplicate read
it removes is a full copy.
- The server does not order either list, and consumers sort for display,
so filtering on the client keeps behavior.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser). 1M-token context window. Extended
thinking was off.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:42:47 -06:00

343 lines
15 KiB
TypeScript

import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { RelativeTime } from "@/pages/tools/shared";
import { CopyableGatewayUrl } from "./CopyableGatewayUrl";
import { NewGatewayDialog, gatewaysQueryKey } from "./NewGatewayDialog";
import { gatewayTabHref } from "./gateway-tabs";
import {
activeTokenCount,
allowedToolsLabel,
deriveGatewayApps,
expiringTokenCount,
formatScope,
isGatewayOn,
latestTokenActivity,
} from "./gateway-helpers";
import { describeError } from "@/api/errors";
import { companyProjectsQuery } from "@/lib/company-projects-query";
export function GatewaysList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [search, setSearch] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
setBreadcrumbs([
{ label: "Connectors", href: "/apps" },
{ label: "Gateways" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs]);
const gatewaysQuery = useQuery({
queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGateways(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const projectsQuery = useQuery({
...companyProjectsQuery(selectedCompanyId ?? "__none__"),
enabled: !!selectedCompanyId,
});
const gatewaysView = useQueryView(gatewaysQuery);
const profileById = useMemo(
() => new Map((profilesQuery.data?.profiles ?? []).map((profile) => [profile.id, profile])),
[profilesQuery.data],
);
const agentNames = useMemo(
() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent.name])),
[agentsQuery.data],
);
const projectNames = useMemo(
() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project.name])),
[projectsQuery.data],
);
const toggleMutation = useMutation({
mutationFn: ({ gateway }: { gateway: ToolMcpGatewayWithTokens }) =>
toolsApi.updateGateway(selectedCompanyId!, gateway.id, {
status: isGatewayOn(gateway) ? "disabled" : "active",
}),
onSuccess: async (gateway) => {
pushToast({
title: gateway.status === "active" ? "Gateway on" : "Gateway off",
body:
gateway.status === "active"
? `${gateway.name} is exposing its tools again.`
: `${gateway.name} is off — every client goes silent.`,
tone: "success",
});
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't update the gateway",
body: describeError(error).body,
tone: "error",
}),
});
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage gateways.</div>;
}
const gateways = gatewaysQuery.data?.gateways ?? [];
const term = search.trim().toLowerCase();
const filtered = term
? gateways.filter((gateway) => {
const scope = formatScope(gateway, projectNames, agentNames).toLowerCase();
return (
gateway.name.toLowerCase().includes(term) ||
gateway.displaySlug.toLowerCase().includes(term) ||
scope.includes(term)
);
})
: gateways;
return (
<div className="max-w-5xl space-y-5">
<header className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Apps</h1>
<p className="text-sm text-muted-foreground">
A gateway is one safe MCP endpoint that exposes only the apps you assign. Hand it to a client
like Cursor or Claude Desktop.
</p>
</header>
{gatewaysQuery.isLoading || gatewaysView.kind === "reconnecting" ? (
<div className="space-y-3 pt-2">
<Skeleton className="h-9 w-full max-w-sm" />
<Skeleton className="h-52 w-full" />
</div>
) : gatewaysView.kind === "error" ? (
<QueryErrorState
error={gatewaysQuery.error}
action="load gateways"
onRetry={gatewaysView.retry}
retrying={gatewaysView.isFetching}
/>
) : gateways.length === 0 ? (
<EmptyGateways onCreate={() => setCreating(true)} />
) : (
<div className="space-y-4 pt-1">
<div data-canonical-toolbar="gateways" role="toolbar" aria-label="Gateway controls" className="flex flex-wrap items-center justify-between gap-3">
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name, app, or owner"
className="pl-9"
aria-label="Search gateways"
/>
</div>
<Button data-toolbar-primary className="ml-auto" onClick={() => setCreating(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New gateway
</Button>
</div>
{(() => {
const rows = filtered.map((gateway) => {
const profile = profileById.get(gateway.profileId);
const apps = deriveGatewayApps(
profile,
applicationsQuery.data?.applications ?? [],
connectionsQuery.data?.connections ?? [],
);
return {
gateway,
profile,
scope: formatScope(gateway, projectNames, agentNames),
appsLabel: `${apps.length} ${apps.length === 1 ? "app" : "apps"}${
profile ? ` · ${allowedToolsLabel(profile)}` : ""
}`,
active: activeTokenCount(gateway),
expiring: expiringTokenCount(gateway),
lastUsed: latestTokenActivity(gateway),
href: gatewayTabHref(gateway.id, "overview"),
};
});
const toggle = (gateway: ToolMcpGatewayWithTokens) => (
<ToggleSwitch
checked={isGatewayOn(gateway)}
disabled={toggleMutation.isPending}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggleMutation.mutate({ gateway })}
aria-label={`Turn ${gateway.name} ${isGatewayOn(gateway) ? "off" : "on"}`}
/>
);
const empty = (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No gateways match “{search.trim()}”.
</div>
);
return (
<>
{/* Desktop / tablet: full table. */}
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
<table className="w-full min-w-(--sz-40rem) text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="whitespace-nowrap px-4 py-2.5">Gateway</th>
<th className="whitespace-nowrap px-4 py-2.5">Scope</th>
<th className="whitespace-nowrap px-4 py-2.5">Apps</th>
<th className="whitespace-nowrap px-4 py-2.5">Tokens</th>
<th className="whitespace-nowrap px-4 py-2.5">Last used</th>
<th className="whitespace-nowrap px-4 py-2.5 text-right">On</th>
</tr>
</thead>
<tbody>
{rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
<tr
key={gateway.id}
onClick={() => navigate(href)}
className="cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30"
>
<td className="w-64 max-w-64 whitespace-nowrap px-4 py-3">
<div className="font-medium text-foreground">{gateway.name}</div>
<CopyableGatewayUrl endpointPath={gateway.endpointPath} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">{scope}</td>
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">{appsLabel}</td>
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">
{active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
</td>
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">
{lastUsed ? <RelativeTime value={lastUsed} /> : "—"}
</td>
<td className="px-4 py-3">
<div className="flex justify-end">{toggle(gateway)}</div>
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6}>{empty}</td>
</tr>
) : null}
</tbody>
</table>
</div>
{/* Mobile: stacked cards so the On toggle stays reachable and thumb-sized. */}
<div className="space-y-3 sm:hidden">
{rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
<div
key={gateway.id}
onClick={() => navigate(href)}
className="cursor-pointer rounded-lg border border-border p-4 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{gateway.name}</div>
<CopyableGatewayUrl endpointPath={gateway.endpointPath} />
</div>
<div className="shrink-0">{toggle(gateway)}</div>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<MobileField label="Scope" value={scope} />
<MobileField label="Apps" value={appsLabel} />
<MobileField
label="Tokens"
value={`${active} active${expiring > 0 ? ` · ${expiring} expiring` : ""}`}
/>
<MobileField
label="Last used"
value={lastUsed ? <RelativeTime value={lastUsed} /> : "—"}
/>
</dl>
</div>
))}
{rows.length === 0 ? (
<div className="rounded-lg border border-border">{empty}</div>
) : null}
</div>
</>
);
})()}
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-sm font-semibold text-foreground">Why a gateway?</div>
<p className="mt-1 text-sm text-muted-foreground">
You pick which apps go through it, who can use it, and how. Revoke the token, the whole
gateway goes silent — no app-by-app cleanup.
</p>
</div>
</div>
)}
<NewGatewayDialog
companyId={selectedCompanyId}
open={creating}
onOpenChange={setCreating}
onCreated={(gatewayId) => navigate(gatewayTabHref(gatewayId, "overview"))}
/>
</div>
);
}
/** One label:value pair inside a mobile stacked card. */
function MobileField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="mt-0.5 truncate text-foreground">{value}</dd>
</div>
);
}
function EmptyGateways({ onCreate }: { onCreate: () => void }) {
return (
<div className="rounded-2xl border border-dashed border-border p-12 text-center">
<h2 className="text-lg font-semibold text-foreground">No gateways yet</h2>
<p className="mx-auto mt-2 max-w-sm text-sm text-muted-foreground">
Group your connected apps into one safe endpoint you can hand to a client, then revoke it in one
move.
</p>
<Button className="mt-5" onClick={onCreate}>
<Plus className="mr-1.5 h-4 w-4" />
New gateway
</Button>
</div>
);
}