mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## 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>
343 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|