diff --git a/api/oss/src/apis/fastapi/tools/models.py b/api/oss/src/apis/fastapi/tools/models.py index cb0a82ba86..144401a091 100644 --- a/api/oss/src/apis/fastapi/tools/models.py +++ b/api/oss/src/apis/fastapi/tools/models.py @@ -12,6 +12,7 @@ # Tool Catalog ToolCatalogAction, ToolCatalogActionDetails, + ToolCatalogCategory, ToolCatalogIntegration, ToolCatalogIntegrationDetails, ToolCatalogProvider, @@ -60,6 +61,11 @@ class ToolCatalogIntegrationsResponse(BaseModel): ] = [] +class ToolCatalogCategoriesResponse(BaseModel): + count: int = 0 + categories: List[ToolCatalogCategory] = [] + + class ToolCatalogActionResponse(BaseModel): count: int = 0 action: Optional[Union[ToolCatalogAction, ToolCatalogActionDetails]] = None diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 529a07af9e..703f17182c 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -19,6 +19,7 @@ from oss.src.apis.fastapi.tools.models import ( ToolCatalogActionResponse, ToolCatalogActionsResponse, + ToolCatalogCategoriesResponse, ToolCatalogIntegrationResponse, ToolCatalogIntegrationsResponse, ToolCatalogProviderResponse, @@ -173,6 +174,14 @@ def __init__( response_model=ToolCatalogIntegrationsResponse, response_model_exclude_none=True, ) + self.router.add_api_route( + "/catalog/providers/{provider_key}/categories/", + self.list_categories, + methods=["GET"], + operation_id="list_tool_categories", + response_model=ToolCatalogCategoriesResponse, + response_model_exclude_none=True, + ) self.router.add_api_route( "/catalog/providers/{provider_key}/integrations/{integration_key}", self.get_integration, @@ -392,6 +401,7 @@ async def list_integrations( *, search: Optional[str] = Query(default=None), sort_by: Optional[str] = Query(default=None), + category: Optional[str] = Query(default=None), limit: Optional[int] = Query(default=None), cursor: Optional[str] = Query(default=None), full_details: bool = Query(default=False), @@ -408,6 +418,7 @@ async def list_integrations( "provider_key": provider_key, "search": search, "sort_by": sort_by, + "category": category, "limit": limit, "cursor": cursor, "full_details": full_details, @@ -425,6 +436,7 @@ async def list_integrations( provider_key=provider_key, search=search, sort_by=sort_by, + category=category, limit=limit, cursor=cursor, ) @@ -447,6 +459,49 @@ async def list_integrations( return response + @intercept_exceptions() + @handle_adapter_exceptions() + async def list_categories( + self, + request: Request, + provider_key: str, + ) -> ToolCatalogCategoriesResponse: + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TOOLS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + cache_key = {"provider_key": provider_key} + cached = await get_cache( + project_id=None, # catalog is global; not per-project + namespace="tools:catalog:categories", + key=cache_key, + model=ToolCatalogCategoriesResponse, + ) + if cached: + return cached + + categories = await self.tools_service.list_categories( + provider_key=provider_key, + ) + response = ToolCatalogCategoriesResponse( + count=len(categories), + categories=categories, + ) + + await set_cache( + project_id=None, # catalog is global; not per-project + namespace="tools:catalog:categories", + key=cache_key, + value=response, + ttl=30 * 60, # 30 minutes — categories change rarely + ) + + return response + @intercept_exceptions() @handle_adapter_exceptions() async def get_integration( diff --git a/api/oss/src/core/gateway/catalog/dtos.py b/api/oss/src/core/gateway/catalog/dtos.py index 854cc9330b..f5b7f05960 100644 --- a/api/oss/src/core/gateway/catalog/dtos.py +++ b/api/oss/src/core/gateway/catalog/dtos.py @@ -52,3 +52,10 @@ class CatalogIntegrationsPage(BaseModel): integrations: List[CatalogIntegration] = [] next_cursor: Optional[str] = None total: int = 0 + + +class CatalogCategory(BaseModel): + """A toolkit category the catalog can be filtered by (e.g. "developer-tools").""" + + id: str + name: str diff --git a/api/oss/src/core/gateway/catalog/interfaces.py b/api/oss/src/core/gateway/catalog/interfaces.py index e0a58be8e4..b82bc9ba7b 100644 --- a/api/oss/src/core/gateway/catalog/interfaces.py +++ b/api/oss/src/core/gateway/catalog/interfaces.py @@ -2,6 +2,7 @@ from typing import List, Optional from oss.src.core.gateway.catalog.dtos import ( + CatalogCategory, CatalogIntegration, CatalogIntegrationsPage, CatalogProvider, @@ -25,10 +26,14 @@ async def list_integrations( *, search: Optional[str] = None, sort_by: Optional[str] = None, + category: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None, ) -> CatalogIntegrationsPage: ... + @abstractmethod + async def list_categories(self) -> List[CatalogCategory]: ... + @abstractmethod async def get_integration( self, diff --git a/api/oss/src/core/gateway/catalog/providers/composio/adapter.py b/api/oss/src/core/gateway/catalog/providers/composio/adapter.py index 1f03732b08..68a369dfac 100644 --- a/api/oss/src/core/gateway/catalog/providers/composio/adapter.py +++ b/api/oss/src/core/gateway/catalog/providers/composio/adapter.py @@ -16,6 +16,7 @@ from oss.src.utils.logging import get_module_logger from oss.src.core.gateway.catalog.dtos import ( CatalogAuthScheme, + CatalogCategory, CatalogIntegration, CatalogIntegrationsPage, CatalogProvider, @@ -117,6 +118,7 @@ async def list_integrations( *, search: Optional[str] = None, sort_by: Optional[str] = None, + category: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None, ) -> CatalogIntegrationsPage: @@ -127,6 +129,8 @@ async def list_integrations( params["search"] = search if sort_by: params["sort_by"] = sort_by + if category: + params["category"] = category if cursor: params["cursor"] = cursor @@ -166,11 +170,61 @@ async def list_integrations( total=total_items, ) + # How many top toolkits to sample when deriving the category nav, and how many categories to + # surface. Composio's /toolkits/categories returns ~800 raw tags (incl. junk like "tag1" and + # auto-slugged names no toolkit actually carries → empty filters), so it's unusable as a nav. + _CATEGORY_SAMPLE_SIZE = 200 + _CATEGORY_LIMIT = 30 + + async def list_categories(self) -> List[CatalogCategory]: + # Derive a focused nav from the most-used toolkits' OWN category tags: every category comes + # from a real toolkit (so filtering by its id is guaranteed to return results), ranked by how + # many popular toolkits carry it. Self-maintaining — no hardcoded allowlist. + try: + resp = await self._client.get( + f"{self.api_url}/toolkits", + headers=self._headers(), + params={"limit": self._CATEGORY_SAMPLE_SIZE, "sort_by": "usage"}, + timeout=30.0, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="list_categories", + detail=composio_error_detail(e), + ) from e + + items_raw = data.get("items", []) if isinstance(data, dict) else data + counts: Dict[str, int] = {} + names: Dict[str, str] = {} + for item in items_raw or []: + meta = item.get("meta") or {} if isinstance(item, dict) else {} + for cat in meta.get("categories") or []: + if isinstance(cat, dict): + cid = cat.get("id") or cat.get("slug") or cat.get("name") + cname = cat.get("name") or cid + else: + cid = cname = cat + if not cid: + continue + cid = str(cid) + counts[cid] = counts.get(cid, 0) + 1 + names.setdefault(cid, str(cname)) + + ranked = sorted(counts.items(), key=lambda kv: (-kv[1], names[kv[0]].lower())) + return [ + CatalogCategory(id=cid, name=names[cid]) + for cid, _ in ranked[: self._CATEGORY_LIMIT] + ] + # --------------------------------------------------------------------------- # Parsers # --------------------------------------------------------------------------- + _AUTH_SCHEME_MAP: Dict[str, CatalogAuthScheme] = { "oauth": CatalogAuthScheme.OAUTH, "oauth2": CatalogAuthScheme.OAUTH, diff --git a/api/oss/src/core/gateway/catalog/service.py b/api/oss/src/core/gateway/catalog/service.py index c3beac49e6..73fe134e93 100644 --- a/api/oss/src/core/gateway/catalog/service.py +++ b/api/oss/src/core/gateway/catalog/service.py @@ -8,6 +8,7 @@ from typing import List, Optional from oss.src.core.gateway.catalog.dtos import ( + CatalogCategory, CatalogIntegration, CatalogIntegrationsPage, CatalogProvider, @@ -49,6 +50,7 @@ async def list_integrations( # search: Optional[str] = None, sort_by: Optional[str] = None, + category: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None, ) -> CatalogIntegrationsPage: @@ -56,10 +58,19 @@ async def list_integrations( return await adapter.list_integrations( search=search, sort_by=sort_by, + category=category, limit=limit, cursor=cursor, ) + async def list_categories( + self, + *, + provider_key: str, + ) -> List[CatalogCategory]: + adapter = self.adapter_registry.get(provider_key) + return await adapter.list_categories() + async def get_integration( self, *, diff --git a/api/oss/src/core/tools/dtos.py b/api/oss/src/core/tools/dtos.py index 55b115bd73..823382753b 100644 --- a/api/oss/src/core/tools/dtos.py +++ b/api/oss/src/core/tools/dtos.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field from oss.src.core.gateway.catalog.dtos import ( + CatalogCategory, CatalogIntegration, CatalogProvider, ) @@ -73,6 +74,10 @@ class ToolCatalogIntegrationDetails(ToolCatalogIntegration): actions: Optional[List[ToolCatalogAction]] = None +class ToolCatalogCategory(CatalogCategory): + pass + + class ToolCatalogProvider(CatalogProvider): key: ToolProviderKind diff --git a/api/oss/src/core/tools/service.py b/api/oss/src/core/tools/service.py index 63e80a8c8f..0fad6cc9bd 100644 --- a/api/oss/src/core/tools/service.py +++ b/api/oss/src/core/tools/service.py @@ -17,6 +17,7 @@ ToolAuthScheme, ToolCatalogActionDetails, ToolCatalogActionsPage, + ToolCatalogCategory, ToolCatalogIntegration, ToolCatalogIntegrationsPage, ToolCatalogProvider, @@ -98,6 +99,7 @@ async def list_integrations( # search: Optional[str] = None, sort_by: Optional[str] = None, + category: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None, ) -> ToolCatalogIntegrationsPage: @@ -105,6 +107,7 @@ async def list_integrations( provider_key=provider_key, search=search, sort_by=sort_by, + category=category, limit=limit, cursor=cursor, ) @@ -118,6 +121,16 @@ async def list_integrations( total=page.total, ) + async def list_categories( + self, + *, + provider_key: str, + ) -> List[ToolCatalogCategory]: + categories = await self.catalog_service.list_categories( + provider_key=provider_key, + ) + return [ToolCatalogCategory.model_validate(c.model_dump()) for c in categories] + async def get_integration( self, *, diff --git a/web/packages/agenta-entities/src/gatewayTool/api/api.ts b/web/packages/agenta-entities/src/gatewayTool/api/api.ts index 0611cc8097..a114cd8eaf 100644 --- a/web/packages/agenta-entities/src/gatewayTool/api/api.ts +++ b/web/packages/agenta-entities/src/gatewayTool/api/api.ts @@ -7,11 +7,18 @@ * parallel set of DTOs. */ +import {getAgentaApiUrl} from "@agenta/shared/api" +import {projectIdAtom} from "@agenta/shared/state" +import {getDefaultStore} from "jotai" + +import {safeParseWithLogging} from "../../shared" +import {toolCatalogCategoriesResponseSchema} from "../core" import type { ToolCall, ToolCallResponse, ToolCatalogActionResponse, ToolCatalogActionsResponse, + ToolCatalogCategoriesResponse, ToolCatalogIntegrationResponse, ToolCatalogIntegrationsResponse, ToolCatalogProvidersResponse, @@ -30,8 +37,21 @@ export const fetchToolProviders = async (): Promise => { + // `category` isn't modelled on Fern's ListToolIntegrationsRequest yet (the + // OpenAPI spec hasn't been regenerated) but the backend honours it as a query + // param — pass it through alongside the `project_id` scope. + const scope = projectScopedRequest() + const requestOptions = params?.category + ? {queryParams: {...(scope?.queryParams ?? {}), category: params.category}} + : scope return getToolsClient().listToolIntegrations( { provider_key: providerKey, @@ -40,8 +60,35 @@ export const fetchToolIntegrations = async ( limit: params?.limit, cursor: params?.cursor, }, - projectScopedRequest(), + requestOptions, + ) +} + +export const fetchToolCategories = async ( + providerKey: string, +): Promise => { + // New catalog endpoint not yet in the Fern client — call it directly with the + // same cookie-auth + project scope the SDK uses. Move to the Fern client on regen. + const projectId = getDefaultStore().get(projectIdAtom) + const url = new URL( + `${getAgentaApiUrl()}/tools/catalog/providers/${encodeURIComponent( + providerKey, + )}/categories/`, + ) + if (projectId) url.searchParams.set("project_id", projectId) + + const res = await fetch(url.toString(), {credentials: "include"}) + if (!res.ok) { + throw new Error(`fetchToolCategories failed: HTTP ${res.status}`) + } + // Boundary validation: this endpoint isn't Fern-typed, so the local schema is the + // only drift check. On a shape mismatch we log and fall back to an empty list. + const data = safeParseWithLogging( + toolCatalogCategoriesResponseSchema, + await res.json(), + "[fetchToolCategories]", ) + return {count: data?.count ?? 0, categories: data?.categories ?? []} } export const fetchToolIntegrationDetail = async ( diff --git a/web/packages/agenta-entities/src/gatewayTool/api/index.ts b/web/packages/agenta-entities/src/gatewayTool/api/index.ts index 450a984d05..9371501db0 100644 --- a/web/packages/agenta-entities/src/gatewayTool/api/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/api/index.ts @@ -5,6 +5,7 @@ export { executeToolCall, fetchToolActionDetail, fetchToolActions, + fetchToolCategories, fetchToolConnection, fetchToolIntegrationDetail, fetchToolIntegrations, diff --git a/web/packages/agenta-entities/src/gatewayTool/core/dedupe.ts b/web/packages/agenta-entities/src/gatewayTool/core/dedupe.ts new file mode 100644 index 0000000000..3f70ae40fe --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTool/core/dedupe.ts @@ -0,0 +1,23 @@ +/** + * Order-preserving dedupe by a derived key. + * + * Guards the catalog lists against duplicate React keys — Composio's categories + * endpoint returns duplicate slugs, and paginated integration cursors can overlap + * and repeat an entry. A duplicate `key`/`id` crashes the list render, so both the + * category and integration hooks funnel their raw server data through this helper. + * Items whose key is falsy (null/undefined/empty) are dropped along with repeats. + */ +export function dedupeBy( + items: readonly T[], + keyOf: (item: T) => string | null | undefined, +): T[] { + const seen = new Set() + const out: T[] = [] + for (const item of items) { + const key = keyOf(item) + if (!key || seen.has(key)) continue + seen.add(key) + out.push(item) + } + return out +} diff --git a/web/packages/agenta-entities/src/gatewayTool/core/index.ts b/web/packages/agenta-entities/src/gatewayTool/core/index.ts index fa4ab36cb5..2b39cb2676 100644 --- a/web/packages/agenta-entities/src/gatewayTool/core/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/core/index.ts @@ -9,6 +9,8 @@ export type { ToolCatalogActionDetails, ToolCatalogActionResponse, ToolCatalogActionsResponse, + ToolCatalogCategory, + ToolCatalogCategoriesResponse, ToolCatalogIntegration, ToolCatalogIntegrationDetails, ToolCatalogIntegrationResponse, @@ -30,3 +32,5 @@ export type { ToolResultData, } from "./types" export {isConnectionActive, isConnectionValid} from "./types" +export {toolCatalogCategorySchema, toolCatalogCategoriesResponseSchema} from "./types" +export {dedupeBy} from "./dedupe" diff --git a/web/packages/agenta-entities/src/gatewayTool/core/types.ts b/web/packages/agenta-entities/src/gatewayTool/core/types.ts index b72f50df6f..fc7a841976 100644 --- a/web/packages/agenta-entities/src/gatewayTool/core/types.ts +++ b/web/packages/agenta-entities/src/gatewayTool/core/types.ts @@ -14,6 +14,7 @@ */ import type {AgentaApi} from "@agentaai/api-client" +import {z} from "zod" // --------------------------------------------------------------------------- // Catalog browse @@ -37,6 +38,28 @@ export type ToolCatalogActionDetails = AgentaApi.ToolCatalogActionDetails export type ToolCatalogActionResponse = AgentaApi.ToolCatalogActionResponse export type ToolCatalogActionsResponse = AgentaApi.ToolCatalogActionsResponse +// Categories are a new catalog endpoint not yet in the Fern client, so — unlike every +// other call in this domain — nothing types its wire shape at the boundary. The zod +// schema below is that missing drift check; `fetchToolCategories` validates against it. +// `.passthrough()` tolerates backend `extra="allow"` fields; defaults keep a malformed +// payload from crashing the drawer (spec §7: a categories failure must not break browse). +export const toolCatalogCategorySchema = z + .object({ + id: z.string(), + name: z.string(), + }) + .passthrough() + +export const toolCatalogCategoriesResponseSchema = z + .object({ + count: z.number().optional().default(0), + categories: z.array(toolCatalogCategorySchema).optional().default([]), + }) + .passthrough() + +export type ToolCatalogCategory = z.infer +export type ToolCatalogCategoriesResponse = z.infer + // --------------------------------------------------------------------------- // Connections // --------------------------------------------------------------------------- diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts index 9571410a10..e8aa2269cd 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts @@ -7,8 +7,10 @@ export { export { toolCatalogIntegrationsInfiniteAtom, toolIntegrationsSearchAtom, + toolIntegrationsCategoryAtom, useToolCatalogIntegrations, } from "./useToolCatalogIntegrations" +export {toolCatalogCategoriesQueryAtom, useToolCatalogCategories} from "./useToolCatalogCategories" export {useToolConnectionActions} from "./useToolConnectionActions" export {toolConnectionQueryAtomFamily, useToolConnectionQuery} from "./useToolConnectionQuery" export {toolConnectionsQueryAtom, useToolConnectionsQuery} from "./useToolConnectionsQuery" diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogCategories.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogCategories.ts new file mode 100644 index 0000000000..2899711b72 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogCategories.ts @@ -0,0 +1,39 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomWithQuery} from "jotai-tanstack-query" + +import {fetchToolCategories} from "../api" +import {dedupeBy} from "../core" +import type {ToolCatalogCategoriesResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" + +// Categories change rarely; cache them generously. Failure here must NOT break the +// rest of the drawer (ALL APPS + search still work) — consumers read `error` and +// simply omit the "Browse by category" group. +export const toolCatalogCategoriesQueryAtom = atomWithQuery(() => ({ + queryKey: ["tools", "catalog", "categories", DEFAULT_PROVIDER], + queryFn: () => fetchToolCategories(DEFAULT_PROVIDER), + staleTime: 30 * 60_000, + refetchOnWindowFocus: false, + retry: 1, +})) + +export const useToolCatalogCategories = () => { + const query = useAtomValue(toolCatalogCategoriesQueryAtom) + + // Composio's categories endpoint returns duplicate slugs — dedupe by id so React keys stay + // unique (a duplicate key crashes the list render). + const categories = useMemo( + () => dedupeBy(query.data?.categories ?? [], (c) => c?.id), + [query.data?.categories], + ) + + return { + categories, + isLoading: query.isPending, + error: query.error, + refetch: query.refetch, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts index 65e379b79c..dadf28777a 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts @@ -4,6 +4,7 @@ import {atom, useAtomValue, useSetAtom} from "jotai" import {atomWithInfiniteQuery} from "jotai-tanstack-query" import {fetchToolIntegrations} from "../api" +import {dedupeBy} from "../core" import type { ToolCatalogIntegration, ToolCatalogIntegrationDetails, @@ -19,16 +20,31 @@ const PREFETCH = 2 // Server-side search atom — set by the drawer, drives the query export const toolIntegrationsSearchAtom = atom("") +// Active category filter (Composio `category` slug), or null for "all apps". Search +// overrides it — an active search ignores the category axis (flat results). +export const toolIntegrationsCategoryAtom = atom(null) + export const toolCatalogIntegrationsInfiniteAtom = atomWithInfiniteQuery((get) => { const search = get(toolIntegrationsSearchAtom) const effectiveSearch = search.length >= 3 ? search : "" + // Search flattens across all categories, so the category filter only applies + // when there is no active search. + const category = effectiveSearch ? null : get(toolIntegrationsCategoryAtom) return { - queryKey: ["tools", "catalog", "integrations", DEFAULT_PROVIDER, effectiveSearch], + queryKey: [ + "tools", + "catalog", + "integrations", + DEFAULT_PROVIDER, + effectiveSearch, + category ?? "", + ], queryFn: async ({pageParam}) => fetchToolIntegrations(DEFAULT_PROVIDER, { search: effectiveSearch || undefined, + category: category || undefined, limit: CHUNK_SIZE, cursor: (pageParam as string) || undefined, }), @@ -42,10 +58,16 @@ export const toolCatalogIntegrationsInfiniteAtom = export const useToolCatalogIntegrations = () => { const query = useAtomValue(toolCatalogIntegrationsInfiniteAtom) const setSearch = useSetAtom(toolIntegrationsSearchAtom) + const setCategory = useSetAtom(toolIntegrationsCategoryAtom) const integrations = useMemo(() => { const pages = query.data?.pages ?? [] - return pages.flatMap((p) => p.integrations ?? []) + // Dedupe by key across pagination boundaries — a cursor overlap can repeat an integration, + // and duplicate React keys crash the grid render. + return dedupeBy( + pages.flatMap((p) => p.integrations ?? []), + (i) => i?.key, + ) }, [query.data?.pages]) const total = useMemo(() => { @@ -91,5 +113,7 @@ export const useToolCatalogIntegrations = () => { error: query.error, requestMore, setSearch, + setCategory, + refetch: query.refetch, } } diff --git a/web/packages/agenta-entities/src/gatewayTool/index.ts b/web/packages/agenta-entities/src/gatewayTool/index.ts index f4bf10a015..7efd16d168 100644 --- a/web/packages/agenta-entities/src/gatewayTool/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/index.ts @@ -26,6 +26,8 @@ export type { ToolCatalogActionDetails, ToolCatalogActionResponse, ToolCatalogActionsResponse, + ToolCatalogCategory, + ToolCatalogCategoriesResponse, ToolCatalogIntegration, ToolCatalogIntegrationDetails, ToolCatalogIntegrationResponse, @@ -93,14 +95,17 @@ export { toolActionDetailQueryFamily, toolActionsSearchAtom, toolCatalogActionsInfiniteFamily, + toolCatalogCategoriesQueryAtom, toolCatalogIntegrationsInfiniteAtom, toolConnectionQueryAtomFamily, toolConnectionsQueryAtom, toolIntegrationConnectionsAtomFamily, toolIntegrationDetailQueryFamily, + toolIntegrationsCategoryAtom, toolIntegrationsSearchAtom, useToolActionDetail, useToolCatalogActions, + useToolCatalogCategories, useToolCatalogIntegrations, useToolConnectionActions, useToolConnectionQuery, diff --git a/web/packages/agenta-entities/tests/unit/gatewayTool-dedupe.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTool-dedupe.test.ts new file mode 100644 index 0000000000..52b04d3aa7 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/gatewayTool-dedupe.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for dedupeBy — the crash-guard shared by the tool-catalog category and + * integration hooks. Composio returns duplicate category slugs and paginated cursors + * can overlap; a duplicate React key crashes the list render (see + * `src/gatewayTool/hooks/useToolCatalog*.ts`). These tests pin the guard so a future + * refactor to pagination or the category filter can't silently reintroduce the crash. + */ +import {describe, it, expect} from "vitest" + +import {dedupeBy} from "../../src/gatewayTool/core/dedupe" + +describe("dedupeBy", () => { + it("removes later duplicates, keeping first occurrence and order", () => { + const items = [ + {id: "a", n: 1}, + {id: "b", n: 2}, + {id: "a", n: 3}, + {id: "c", n: 4}, + {id: "b", n: 5}, + ] + expect(dedupeBy(items, (i) => i.id)).toEqual([ + {id: "a", n: 1}, + {id: "b", n: 2}, + {id: "c", n: 4}, + ]) + }) + + it("drops items whose key is falsy (null, undefined, empty)", () => { + const items = [ + {key: "x"}, + {key: ""}, + {key: null as string | null}, + {key: undefined as string | undefined}, + {key: "y"}, + ] + expect(dedupeBy(items, (i) => i.key)).toEqual([{key: "x"}, {key: "y"}]) + }) + + it("tolerates null/undefined entries via the key accessor", () => { + const items = [{id: "a"}, null, undefined, {id: "a"}, {id: "b"}] + expect(dedupeBy(items, (i) => i?.id)).toEqual([{id: "a"}, {id: "b"}]) + }) + + it("returns an empty array for empty input", () => { + expect(dedupeBy([], (i: {id: string}) => i.id)).toEqual([]) + }) + + it("does not mutate the input array", () => { + const items = [{id: "a"}, {id: "a"}] + const snapshot = [...items] + dedupeBy(items, (i) => i.id) + expect(items).toEqual(snapshot) + }) +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx index 5ed8936431..125166fb9f 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx @@ -18,6 +18,7 @@ import { isConnectionValid, toolIntegrationsSearchAtom, useToolCatalogActions, + useToolCatalogCategories, useToolCatalogIntegrations, useToolConnectionsQuery, type ToolCatalogAction, @@ -95,14 +96,23 @@ function useToolIntegrationsList() { const r = useToolCatalogIntegrations() return { integrations: r.integrations, + total: r.total, hasNextPage: r.hasNextPage, isFetchingNextPage: r.isFetchingNextPage, isLoading: r.isLoading, requestMore: r.requestMore, setSearch, + setCategory: r.setCategory, + error: r.error, + refetch: r.refetch, } } +function useToolCategoriesList() { + const r = useToolCatalogCategories() + return {categories: r.categories, isLoading: r.isLoading, error: r.error, refetch: r.refetch} +} + function useToolActionList(integrationKey: string) { const r = useToolCatalogActions(integrationKey) return { @@ -198,10 +208,12 @@ function ToolCatalogContent({ ) return ( -
+
connections={connections} cardVariant="subtle" + fullBleedRail + useCategories={useToolCategoriesList} defaultIntegrationKey={defaultIntegrationKey} isConnectionReady={isConnectionValid} onReconnect={(c) => c.id && reconnect(c.id)} diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/CatalogChooser.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/CatalogChooser.tsx index 30860cdcd9..081f773022 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/CatalogChooser.tsx +++ b/web/packages/agenta-entity-ui/src/drawers/shared/CatalogChooser.tsx @@ -9,7 +9,7 @@ * * Consumers wrap this in their own drawer/section. Dark-safe (`--ag-color*` tokens). */ -import {useEffect, useMemo, useState, type ReactNode} from "react" +import {useEffect, useMemo, useRef, useState, type ReactNode} from "react" import {ScrollSentinel} from "@agenta/ui" import {ArrowClockwise, ArrowLeft, Check, MagnifyingGlass, Plus} from "@phosphor-icons/react" @@ -26,11 +26,27 @@ export interface CatalogChooserProps { isConnectionReady: (c: C) => boolean useIntegrations: () => { integrations: I[] + /** Total matching the active query (for the "N of M" grid header). */ + total?: number hasNextPage: boolean isFetchingNextPage: boolean isLoading: boolean requestMore: () => void setSearch: (s: string) => void + /** Set the active category filter (slug), or null for "all apps". */ + setCategory?: (category: string | null) => void + error?: unknown + refetch?: () => void + } + /** + * Optional category listing. When provided, the rail shows a "Browse by category" group and + * clicking a category filters the grid via `useIntegrations().setCategory`. Omitted (e.g. the + * triggers drawer) → no category group, rail behaves exactly as before. + */ + useCategories?: () => { + categories: {id: string; name: string}[] + isLoading: boolean + error?: unknown } useItems: (integrationKey: string) => { items: T[] @@ -89,6 +105,19 @@ export interface CatalogChooserProps { defaultIntegrationKey?: string /** App-tile appearance. "subtle" drops the rest border (agent playground). @default "bordered" */ cardVariant?: "bordered" | "subtle" + /** + * Full-bleed rail: the recessed rail fills the whole left column edge-to-edge (its own padding) + * and the content pane carries its own padding, instead of both floating inside a padded body. + * The consumer must then drop its own body padding around the chooser. @default false + */ + fullBleedRail?: boolean + /** + * Minimum query length before the search is treated as active — must match the + * server-side threshold in the consumer's `useIntegrations` hook (below it the hook + * ignores the query, so the UI must not switch to "Results" or drop the category + * filter either). @default 3 + */ + searchMinChars?: number } function ItemTrailing({state}: {state: CatalogItemState}) { @@ -474,15 +503,95 @@ function ConnectInvite({ ) } +/** A logo-less rail row for the "Browse by category" group ("All apps" + each category). */ +function CategoryRailRow({ + label, + active, + onClick, +}: { + label: string + active: boolean + onClick: () => void +}) { + return ( + + ) +} + +/** Placeholder rail rows while the category list loads. */ +function CategoryRailSkeleton() { + return ( + <> + {Array.from({length: 6}).map((_, i) => ( +
+ ))} + + ) +} + +/** Placeholder cards for the grid's first-page load (skeleton, not a spinner-on-blank). */ +function CatalogGridSkeleton() { + return ( + <> + {Array.from({length: 8}).map((_, i) => ( +
+ ))} + + ) +} + export function CatalogChooser(props: CatalogChooserProps) { - const {connections, defaultIntegrationKey} = props - const {integrations, hasNextPage, isFetchingNextPage, isLoading, requestMore, setSearch} = - props.useIntegrations() + const {connections, defaultIntegrationKey, searchMinChars = 3} = props + const { + integrations, + total, + hasNextPage, + isFetchingNextPage, + isLoading, + requestMore, + setSearch, + setCategory, + error: integrationsError, + refetch: refetchIntegrations, + } = props.useIntegrations() + + const categoryData = props.useCategories?.() + const hasCategoryGroup = !!props.useCategories + // Integration metadata (name/logo) for connection rows and the selected-connection detail + // must outlive the grid narrowing under a category/search filter — otherwise a connection + // whose app falls outside the active filter (or hasn't paged in yet) degrades to its raw + // integration key. Accumulate a monotonic union of every integration ever loaded so these + // lookups stay stable while the visible grid below stays filtered. + const metaByKeyRef = useRef(new Map()) const byKey = useMemo(() => { - const m = new Map() - integrations.forEach((i) => m.set(props.integration.key(i), i)) - return m + integrations.forEach((i) => metaByKeyRef.current.set(props.integration.key(i), i)) + return new Map(metaByKeyRef.current) }, [integrations, props.integration]) // Per-integration connection state for the grid dot: "active" if any connection is functional, // otherwise "pending" when connections exist but none is ready yet. Ready wins over pending. @@ -502,13 +611,19 @@ export function CatalogChooser(props: CatalogChooserProps) { return () => clearTimeout(t) }, [searchInput, setSearch]) // The integrations search is a shared atom — clear it on unmount so a stale query doesn't - // filter the next open (mirrors the item-search cleanup above). + // filter the next open (mirrors the item-search cleanup above). Same for the category filter. useEffect(() => () => setSearch(""), [setSearch]) - const searching = searchInput.trim().length > 0 + useEffect(() => () => setCategory?.(null), [setCategory]) + // Align the "searching" UI state with the hook's server-side threshold: below it the + // query still shows the category filter, so the header must not flip to "Results" yet. + const searching = searchInput.trim().length >= searchMinChars const [selected, setSelected] = useState<{kind: "conn" | "intg"; id: string} | null>( defaultIntegrationKey ? {kind: "intg", id: defaultIntegrationKey} : null, ) + // Active category filter (rail single-select). Search overrides it (flat results). + const [category, setCategoryState] = useState<{id: string; name: string} | null>(null) + const [showAllConns, setShowAllConns] = useState(false) const [gridEl, setGridEl] = useState(null) const selectedConn = selected?.kind === "conn" @@ -539,47 +654,145 @@ export function CatalogChooser(props: CatalogChooserProps) { const hasConnections = connections.length > 0 + // Single-select rail: search overrides everything, so nothing is highlighted while searching. + const activeConnId = !searching && selected?.kind === "conn" ? selected.id : null + const activeCategoryId = !searching && !selected ? (category?.id ?? null) : null + const allAppsActive = !searching && !selected && !category + + const CONN_CAP = 6 + const visibleConns = showAllConns ? connections : connections.slice(0, CONN_CAP) + + // Picking any rail filter clears an active search (search and filter are mutually exclusive). + const clearSearch = () => { + setSearchInput("") + setSearch("") + } + // Category state (rail highlight) and the query atom must always move together, or the grid + // filters by a category the rail no longer shows. + const clearCategory = () => { + setCategoryState(null) + setCategory?.(null) + } + const pickAll = () => { + setSelected(null) + clearCategory() + clearSearch() + } + const pickCategory = (cat: {id: string; name: string}) => { + setSelected(null) + setCategoryState(cat) + setCategory?.(cat.id) + clearSearch() + } + // A connection is its own rail selection, mutually exclusive with the category filter — + // leaving the category active would filter the browse query behind the detail view and, on + // Back, restore a stale category the user didn't re-pick. + const pickConn = (id: string) => { + setSelected({kind: "conn", id}) + clearCategory() + clearSearch() + } + + const {fullBleedRail} = props + const railPresent = hasConnections || hasCategoryGroup + return ( -
- {hasConnections && ( -
-
- Your connections -
- {connections.map((c) => { - const app = byKey.get(props.connection.integrationKey(c)) - const appName = app - ? props.integration.name(app) - : props.connection.integrationKey(c) - // Distinguish two accounts of the same app: prefer a friendly name, fall - // back to the connection slug so identical apps don't render as duplicates. - const account = - props.connection.name(c)?.trim() || props.connection.slug(c)?.trim() - return ( - { - const id = props.connection.id(c) - if (id) setSelected({kind: "conn", id}) - }} - /> - ) - })} +
+ {railPresent && ( +
+ {hasConnections && ( + // Connections stay pinned at the top — the category list below scrolls on its + // own so scrolling categories never scrolls the connections away. +
+
+ + Your connections + + + {connections.length} + +
+ {visibleConns.map((c) => { + const app = byKey.get(props.connection.integrationKey(c)) + const appName = app + ? props.integration.name(app) + : props.connection.integrationKey(c) + // Distinguish two accounts of the same app: prefer a friendly name, + // fall back to the slug so identical apps don't render as duplicates. + const account = + props.connection.name(c)?.trim() || + props.connection.slug(c)?.trim() + const id = props.connection.id(c) + return ( + id && pickConn(id)} + /> + ) + })} + {connections.length > CONN_CAP && ( + + )} +
+ )} + + {hasCategoryGroup && ( +
+ {hasConnections && ( +
+ )} +
+ Browse by category +
+
+ + {categoryData?.isLoading && categoryData.categories.length === 0 ? ( + + ) : ( + // On categories-error we omit the list — "All apps" + search + // still work (spec §7: a categories failure must not break browse). + categoryData?.categories.map((cat) => ( + pickCategory(cat)} + /> + )) + )} +
+
+ )}
)}
@@ -590,7 +803,7 @@ export function CatalogChooser(props: CatalogChooserProps) { onClick={() => setSelected(null)} className="mb-3 flex shrink-0 cursor-pointer items-center gap-1 self-start border-0 bg-transparent p-0 text-[11px] text-[var(--ag-colorTextSecondary)] hover:text-[var(--ag-colorText)]" > - All apps + Back {selectedConn ? (
@@ -680,6 +893,7 @@ export function CatalogChooser(props: CatalogChooserProps) { <> (props: CatalogChooserProps) { } value={searchInput} onChange={(e) => setSearchInput(e.target.value)} + onKeyDown={(e) => { + // Esc clears a query first; only then falls through to close the drawer. + if (e.key === "Escape" && searchInput) { + e.stopPropagation() + clearSearch() + } + }} /> -
- {searching ? "Search results" : "All apps"} +
+ + {searching ? "Results" : category ? category.name : "All apps"} + + {integrations.length > 0 && ( + + {typeof total === "number" && total > integrations.length + ? `${integrations.length} of ${total}` + : integrations.length} + + )}
- {integrations.map((i) => ( - - setSelected({kind: "intg", id: props.integration.key(i)}) - } - /> - ))} - {!isLoading && integrations.length === 0 && ( -
- {searching - ? `No apps match “${searchInput}”.` - : "No apps found."} + {isLoading && integrations.length === 0 ? ( + + ) : integrationsError && integrations.length === 0 ? ( +
+ + Couldn't load apps. + + {refetchIntegrations && ( + + )}
- )} -
- {(isLoading || isFetchingNextPage) && ( -
- + ) : integrations.length === 0 ? ( +
+ {searching ? ( + <> + No apps match “{searchInput.trim()}”. + + + ) : category ? ( + No apps in {category.name} yet. + ) : ( + No apps found. + )} +
+ ) : ( + <> + {integrations.map((i) => ( + + setSelected({ + kind: "intg", + id: props.integration.key(i), + }) + } + /> + ))} +
+ {isFetchingNextPage && ( +
+ +
+ )} +
- )} - -
+ + )}
)}