From 48e16d1363d3f34d036606946e28a8cfb2e37ea7 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 24 Jun 2026 22:56:06 +0530 Subject: [PATCH] fix(composio): loading skeleton + bounded fetch timeout for Connections (no hardcoded flash) (#4079) --- app/src/lib/composio/catalogCache.ts | 11 ++- app/src/lib/composio/composioApi.test.ts | 68 +++++++++++++++++ app/src/lib/composio/composioApi.ts | 58 +++++++++++++- app/src/lib/composio/hooks.test.ts | 5 +- app/src/lib/composio/hooks.ts | 11 ++- app/src/lib/i18n/ar.ts | 1 + app/src/lib/i18n/bn.ts | 1 + app/src/lib/i18n/de.ts | 1 + app/src/lib/i18n/en.ts | 1 + app/src/lib/i18n/es.ts | 1 + app/src/lib/i18n/fr.ts | 1 + app/src/lib/i18n/hi.ts | 1 + app/src/lib/i18n/id.ts | 1 + app/src/lib/i18n/it.ts | 1 + app/src/lib/i18n/ko.ts | 1 + app/src/lib/i18n/pl.ts | 1 + app/src/lib/i18n/pt.ts | 1 + app/src/lib/i18n/ru.ts | 1 + app/src/lib/i18n/zh-CN.ts | 1 + app/src/pages/Skills.tsx | 68 ++++++++++++++--- .../Skills.composio-catalog.test.tsx | 76 ++++++++++++++++++- 21 files changed, 289 insertions(+), 22 deletions(-) diff --git a/app/src/lib/composio/catalogCache.ts b/app/src/lib/composio/catalogCache.ts index 3232ebe52..7381ffb53 100644 --- a/app/src/lib/composio/catalogCache.ts +++ b/app/src/lib/composio/catalogCache.ts @@ -85,7 +85,14 @@ function isFresh(entry: CachedCatalog | null): boolean { * - Stale-but-present + fetch fail → stale value served (graceful degrade). * - Cold + fetch fail → error propagates to the caller. */ -export async function getToolkitCatalog(): Promise { +export async function getToolkitCatalog(options?: { + /** + * Forwarded to `listToolkits` on a cold/stale fetch. The Connections-page + * hook passes `COMPOSIO_FETCH_TIMEOUT_MS` to bound its loading skeleton; + * omit it to inherit the global RPC default. + */ + timeoutMs?: number; +}): Promise { const cached = readPersisted(); if (cached && isFresh(cached)) return cached.response; @@ -93,7 +100,7 @@ export async function getToolkitCatalog(): Promise { // Snapshot the generation so a mid-flight invalidation makes this fetch's // result non-authoritative (see `generation`). const startGeneration = generation; - const fetchPromise = listToolkits() + const fetchPromise = listToolkits(options) .then(response => { // Only cache the response if no invalidation happened while it was in // flight; otherwise it belongs to a stale tenant. Still return it to diff --git a/app/src/lib/composio/composioApi.test.ts b/app/src/lib/composio/composioApi.test.ts index 6dbb8e0d7..b9a5ce079 100644 --- a/app/src/lib/composio/composioApi.test.ts +++ b/app/src/lib/composio/composioApi.test.ts @@ -6,6 +6,8 @@ import { enableTrigger, listAgentReadyToolkits, listAvailableTriggers, + listConnections, + listToolkits, listTriggers, syncConnection, } from './composioApi'; @@ -192,6 +194,72 @@ describe('listAgentReadyToolkits', () => { }); }); +describe('Connections loading fetches (opt-in bounded timeout)', () => { + // The Connections page clears its loading skeleton only after BOTH of + // these settle (Promise.allSettled in useComposioIntegrations), so it opts + // both into the shorter 8s budget to bound the skeleton window on a cold + // cache against a down backend (#3933). The catalog is safe to time out + // early — it has a 24h stale cache plus a hardcoded fallback. + // + // The timeout is *opt-in*, not the wrapper default: `listConnections` is + // shared by the repo/issue pickers, add-memory-source dialog and connect + // modal poll, where a slow-but-successful 8–30s call must still complete + // (#4079 review). Default callers therefore pass no `timeoutMs`. + const EXPECTED_FETCH_TIMEOUT_MS = 8_000; + + beforeEach(() => { + mockCallCoreRpc.mockReset(); + }); + + it('listToolkits omits timeoutMs by default and unwraps the envelope', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { toolkits: ['gmail', 'slack'] }, + logs: ['composio: 2 toolkit(s) listed'], + }); + + const out = await listToolkits(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_toolkits' }); + expect(mockCallCoreRpc.mock.calls[0][0]).not.toHaveProperty('timeoutMs'); + expect(out.toolkits).toEqual(['gmail', 'slack']); + }); + + it('listToolkits forwards an explicit timeoutMs option', async () => { + mockCallCoreRpc.mockResolvedValue({ result: { toolkits: [] }, logs: [] }); + + await listToolkits({ timeoutMs: EXPECTED_FETCH_TIMEOUT_MS }); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.composio_list_toolkits', + timeoutMs: EXPECTED_FETCH_TIMEOUT_MS, + }); + }); + + it('listConnections omits timeoutMs by default and unwraps the envelope', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { connections: [{ toolkit: 'gmail', status: 'ACTIVE' }] }, + logs: [], + }); + + const out = await listConnections(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_connections' }); + expect(mockCallCoreRpc.mock.calls[0][0]).not.toHaveProperty('timeoutMs'); + expect(out.connections).toHaveLength(1); + }); + + it('listConnections forwards an explicit timeoutMs option', async () => { + mockCallCoreRpc.mockResolvedValue({ result: { connections: [] }, logs: [] }); + + await listConnections({ timeoutMs: EXPECTED_FETCH_TIMEOUT_MS }); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.composio_list_connections', + timeoutMs: EXPECTED_FETCH_TIMEOUT_MS, + }); + }); +}); + describe('deleteConnection', () => { beforeEach(() => { mockCallCoreRpc.mockReset(); diff --git a/app/src/lib/composio/composioApi.ts b/app/src/lib/composio/composioApi.ts index 6479a6b25..9137bd0e2 100644 --- a/app/src/lib/composio/composioApi.ts +++ b/app/src/lib/composio/composioApi.ts @@ -49,10 +49,51 @@ function unwrapCliEnvelope(value: unknown): T { return value as T; } +/** + * Shorter-than-default per-call timeout the Connections page's loading + * state opts into for the two RPCs it waits on (`listToolkits` + + * `listConnections`). + * + * Both fetches are *non-critical on that surface*: the toolkit catalog has + * a 24h stale cache AND a hardcoded `KNOWN_COMPOSIO_TOOLKITS` fallback, so a + * slow or dead backend should degrade to the fallback fast rather than + * pinning the Connections grid on a loading skeleton. The skeleton window is + * bounded by the *slower* of these two calls (the hook clears `loading` only + * after `Promise.allSettled([getToolkitCatalog(), listConnections()])` + * settles — see `useComposioIntegrations` in ./hooks.ts), so BOTH must opt + * into the shorter budget. Without this the window stretches to the global + * `CORE_RPC_TIMEOUT_MS` (30s) on a cold cache against a down backend. + * + * It is deliberately **opt-in** via the `timeoutMs` option rather than the + * wrapper default: `listConnections` is also called by the GitHub repo + * picker, SmartIssuePicker, the add-memory-source dialog and the connect + * modal's poll loop, where a slow-but-successful 8–30s call must still be + * allowed to complete rather than being failed early (#4079 review). + */ +export const COMPOSIO_FETCH_TIMEOUT_MS = 8_000; + +/** Per-call options shared by the bounded read wrappers. */ +interface ComposioReadOptions { + /** + * Override the RPC timeout (ms). Omit to inherit the global + * `CORE_RPC_TIMEOUT_MS`. Pass `COMPOSIO_FETCH_TIMEOUT_MS` only from the + * Connections-page loading path (see the constant's doc comment). + */ + timeoutMs?: number; +} + // ── Read operations ─────────────────────────────────────────────── -export async function listToolkits(): Promise { - const raw = await callCoreRpc({ method: 'openhuman.composio_list_toolkits' }); +export async function listToolkits( + options?: ComposioReadOptions +): Promise { + const raw = await callCoreRpc({ + method: 'openhuman.composio_list_toolkits', + // Timeout is opt-in: the Connections loading skeleton passes the shorter + // budget so the hardcoded fallback surfaces fast (#3933); other callers + // inherit the global default. + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }); return unwrapCliEnvelope(raw); } @@ -74,8 +115,17 @@ export async function listAgentReadyToolkits(): Promise(raw); } -export async function listConnections(): Promise { - const raw = await callCoreRpc({ method: 'openhuman.composio_list_connections' }); +export async function listConnections( + options?: ComposioReadOptions +): Promise { + const raw = await callCoreRpc({ + method: 'openhuman.composio_list_connections', + // Timeout is opt-in (see `listToolkits`): only the Connections loading + // path passes the shorter budget. Shared callers (repo/issue pickers, + // add-memory-source, connect-modal poll) inherit the global default so a + // slow-but-successful call still completes (#4079 review). + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }); return unwrapCliEnvelope(raw); } diff --git a/app/src/lib/composio/hooks.test.ts b/app/src/lib/composio/hooks.test.ts index c35fdb71b..1db118a52 100644 --- a/app/src/lib/composio/hooks.test.ts +++ b/app/src/lib/composio/hooks.test.ts @@ -8,8 +8,9 @@ const mockOpenhumanComposioGetMode = vi.fn(); let sessionToken = 'jwt-abc'; vi.mock('./composioApi', () => ({ - listToolkits: () => mockListToolkits(), - listConnections: () => mockListConnections(), + COMPOSIO_FETCH_TIMEOUT_MS: 8_000, + listToolkits: (options?: { timeoutMs?: number }) => mockListToolkits(options), + listConnections: (options?: { timeoutMs?: number }) => mockListConnections(options), listAgentReadyToolkits: () => mockListAgentReadyToolkits(), })); diff --git a/app/src/lib/composio/hooks.ts b/app/src/lib/composio/hooks.ts index 87d1f10f6..f0c2ea3bf 100644 --- a/app/src/lib/composio/hooks.ts +++ b/app/src/lib/composio/hooks.ts @@ -4,7 +4,7 @@ import { isLocalSessionToken } from '../../utils/localSession'; import { openhumanComposioGetMode } from '../../utils/tauriCommands'; import { getCoreStateSnapshot } from '../coreState/store'; import { getToolkitCatalog, invalidateToolkitCatalogCache } from './catalogCache'; -import { listAgentReadyToolkits, listConnections } from './composioApi'; +import { COMPOSIO_FETCH_TIMEOUT_MS, listAgentReadyToolkits, listConnections } from './composioApi'; import { canonicalizeComposioToolkitSlug } from './toolkitSlug'; import type { ComposioConnection, ComposioToolkitCatalogEntry } from './types'; @@ -99,9 +99,12 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte let nextError: string | null = null; try { + // Bound both fetches so the loading skeleton can't pin past ~8s on a + // cold cache / down backend. This is the only path that opts into the + // shorter budget — see COMPOSIO_FETCH_TIMEOUT_MS. const [toolkitsResult, connectionsResult] = await Promise.allSettled([ - getToolkitCatalog(), - listConnections(), + getToolkitCatalog({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }), + listConnections({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }), ]); if (!mountedRef.current) return; @@ -139,7 +142,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte void refresh(); if (pollIntervalMs <= 0 || fetchEnabled !== true) return; const id = window.setInterval(() => { - void listConnections() + void listConnections({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }) .then(resp => { if (!mountedRef.current) return; setConnections(resp.connections ?? []); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index ef9777efc..8ff44f514 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -479,6 +479,7 @@ const messages: TranslationMap = { 'skills.title': 'الاتصالات', 'skills.search': 'البحث في الاتصالات...', 'skills.noResults': 'لم يتم العثور على اتصالات', + 'skills.loadingIntegrations': 'جارٍ تحميل التكاملات…', 'skills.connect': 'توصيل', 'skills.disconnect': 'قطع الاتصال', 'skills.configure': 'إدارة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 10293e4bf..a6eade320 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -487,6 +487,7 @@ const messages: TranslationMap = { 'skills.title': 'সংযোগ', 'skills.search': 'সংযোগ খুঁজুন...', 'skills.noResults': 'কোনো সংযোগ পাওয়া যায়নি', + 'skills.loadingIntegrations': 'ইন্টিগ্রেশন লোড হচ্ছে…', 'skills.connect': 'সংযুক্ত করুন', 'skills.disconnect': 'সংযোগ বিচ্ছিন্ন', 'skills.configure': 'পরিচালনা', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 7f2ee6d9f..3b94e1bb4 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -502,6 +502,7 @@ const messages: TranslationMap = { 'skills.title': 'Verbindungen', 'skills.search': 'Verbindungen suchen...', 'skills.noResults': 'Keine Verbindungen gefunden', + 'skills.loadingIntegrations': 'Integrationen werden geladen…', 'skills.connect': 'Verbinden', 'skills.disconnect': 'Trennen', 'skills.configure': 'Verwalten', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 30f25f213..fc8708ae2 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -545,6 +545,7 @@ const en: TranslationMap = { 'skills.title': 'Connections', 'skills.search': 'Search connections...', 'skills.noResults': 'No connections found', + 'skills.loadingIntegrations': 'Loading integrations…', 'skills.connect': 'Connect', 'skills.disconnect': 'Disconnect', 'skills.configure': 'Manage', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 15836fa9a..bb8bd2ed4 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -500,6 +500,7 @@ const messages: TranslationMap = { 'skills.title': 'Conexiones', 'skills.search': 'Buscar conexiones...', 'skills.noResults': 'No se encontraron conexiones', + 'skills.loadingIntegrations': 'Cargando integraciones…', 'skills.connect': 'Conectar', 'skills.disconnect': 'Desconectar', 'skills.configure': 'Administrar', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 7136c05a6..05679e30a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -501,6 +501,7 @@ const messages: TranslationMap = { 'skills.title': 'Connexions', 'skills.search': 'Rechercher des connexions…', 'skills.noResults': 'Aucune connexion trouvée', + 'skills.loadingIntegrations': 'Chargement des intégrations…', 'skills.connect': 'Connecter', 'skills.disconnect': 'Déconnecter', 'skills.configure': 'Gérer', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index e0fb13359..a771258a1 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -486,6 +486,7 @@ const messages: TranslationMap = { 'skills.title': 'कनेक्शन', 'skills.search': 'कनेक्शन सर्च करें...', 'skills.noResults': 'कोई कनेक्शन नहीं मिला', + 'skills.loadingIntegrations': 'इंटीग्रेशन लोड हो रहे हैं…', 'skills.connect': 'कनेक्ट करें', 'skills.disconnect': 'डिसकनेक्ट करें', 'skills.configure': 'मैनेज करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c3249a9a9..9b1c16574 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -489,6 +489,7 @@ const messages: TranslationMap = { 'skills.title': 'Koneksi', 'skills.search': 'Cari koneksi...', 'skills.noResults': 'Koneksi tidak ditemukan', + 'skills.loadingIntegrations': 'Memuat integrasi…', 'skills.connect': 'Hubungkan', 'skills.disconnect': 'Putuskan', 'skills.configure': 'Kelola', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index e857c81a7..ee42f92f9 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -497,6 +497,7 @@ const messages: TranslationMap = { 'skills.title': 'Connessioni', 'skills.search': 'Cerca connessioni...', 'skills.noResults': 'Nessuna connessione trovata', + 'skills.loadingIntegrations': 'Caricamento delle integrazioni…', 'skills.connect': 'Connetti', 'skills.disconnect': 'Disconnetti', 'skills.configure': 'Gestisci', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 310ed97f5..83f2473f6 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -486,6 +486,7 @@ const messages: TranslationMap = { 'skills.title': '연결', 'skills.search': '연결 검색...', 'skills.noResults': '연결을 찾을 수 없습니다', + 'skills.loadingIntegrations': '통합을 불러오는 중…', 'skills.connect': '연결', 'skills.disconnect': '연결 해제', 'skills.configure': '관리', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 50355cc1f..a5e4e771b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -492,6 +492,7 @@ const messages: TranslationMap = { 'skills.title': 'Połączenia', 'skills.search': 'Szukaj połączeń...', 'skills.noResults': 'Nie znaleziono połączeń', + 'skills.loadingIntegrations': 'Ładowanie integracji…', 'skills.connect': 'Połącz', 'skills.disconnect': 'Rozłącz', 'skills.configure': 'Zarządzaj', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f65adb4b8..d84cd1b8b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -500,6 +500,7 @@ const messages: TranslationMap = { 'skills.title': 'Conexões', 'skills.search': 'Pesquisar conexões...', 'skills.noResults': 'Nenhuma conexão encontrada', + 'skills.loadingIntegrations': 'Carregando integrações…', 'skills.connect': 'Conectar', 'skills.disconnect': 'Desconectar', 'skills.configure': 'Gerenciar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b3dd4a458..f02fe40b3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -493,6 +493,7 @@ const messages: TranslationMap = { 'skills.title': 'Подключения', 'skills.search': 'Поиск подключений...', 'skills.noResults': 'Подключения не найдены', + 'skills.loadingIntegrations': 'Загрузка интеграций…', 'skills.connect': 'Подключить', 'skills.disconnect': 'Отключить', 'skills.configure': 'Управление', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d3977d193..5b1a9d896 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -469,6 +469,7 @@ const messages: TranslationMap = { 'skills.title': '连接', 'skills.search': '搜索连接...', 'skills.noResults': '未找到连接', + 'skills.loadingIntegrations': '正在加载集成…', 'skills.connect': '连接', 'skills.disconnect': '断开', 'skills.configure': '配置', diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 1b4ada909..ebc535877 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -467,6 +467,7 @@ export default function Skills() { catalogByToolkit: composioCatalogByToolkit = new Map(), connectionByToolkit: composioConnectionByToolkit, connectionsByToolkit: composioConnectionsByToolkit, + loading: composioLoading, error: composioError, refresh: refreshComposio, } = useComposioIntegrations(); @@ -541,25 +542,42 @@ export default function Skills() { const composioCatalogToolkits = useMemo(() => { const normalizedToolkits = composioToolkits.map(slug => canonicalizeComposioToolkitSlug(slug)); - // Prefer the live Composio catalog when the backend supplies it. The - // hardcoded KNOWN_COMPOSIO_TOOLKITS list only fills gaps for older - // cores that predate the dynamic catalog (see - // COMPOSIO_DYNAMIC_CATALOG_PLAN.md) so the grid is never empty. + // Base-list selection (see COMPOSIO_DYNAMIC_CATALOG_PLAN.md / #3933): + // 1. Dynamic catalog present → drive the grid straight off the backend. + // 2. Still fetching (no catalog yet) → render NOTHING from the hardcoded + // list. The grid shows a loading skeleton instead. This is the fix for + // the "flash of stale hardcoded toolkits" that appeared before the + // backend catalog landed. + // 3. Fetch finished with no catalog (a genuine failure, or an older core + // that predates the dynamic catalog) → fall back to the hardcoded + // KNOWN_COMPOSIO_TOOLKITS so the grid is never empty. const dynamicSlugs = Array.from(composioCatalogByToolkit.keys()); const hasDynamicCatalog = dynamicSlugs.length > 0; - const baseSlugs = hasDynamicCatalog ? dynamicSlugs : KNOWN_COMPOSIO_TOOLKITS; + let baseSlugs: readonly string[]; + let source: 'dynamic-backend' | 'loading' | 'hardcoded-fallback'; + if (hasDynamicCatalog) { + baseSlugs = dynamicSlugs; + source = 'dynamic-backend'; + } else if (composioLoading) { + baseSlugs = []; + source = 'loading'; + } else { + baseSlugs = KNOWN_COMPOSIO_TOOLKITS; + source = 'hardcoded-fallback'; + } if (IS_DEV) { const missingKnownToolkits = KNOWN_COMPOSIO_TOOLKITS.filter( slug => !normalizedToolkits.includes(slug) ); console.debug('[skills][composio] building catalog', { - source: hasDynamicCatalog ? 'dynamic-backend' : 'hardcoded-fallback', + source, dynamicCount: dynamicSlugs.length, toolkitCount: composioToolkits.length, connectionCount: composioConnectionByToolkit.size, + loading: composioLoading, hasError: Boolean(composioError), - missingKnownToolkits: hasDynamicCatalog ? [] : missingKnownToolkits, + missingKnownToolkits: source === 'hardcoded-fallback' ? missingKnownToolkits : [], }); } @@ -568,7 +586,13 @@ export default function Skills() { return Array.from(new Set([...baseSlugs, ...normalizedToolkits])).sort((a, b) => a.localeCompare(b) ); - }, [composioToolkits, composioCatalogByToolkit, composioConnectionByToolkit, composioError]); + }, [ + composioToolkits, + composioCatalogByToolkit, + composioConnectionByToolkit, + composioLoading, + composioError, + ]); // Unified item list const allItems: SkillItem[] = useMemo(() => { @@ -1077,7 +1101,33 @@ export default function Skills() { )} {!showLocalComposioApiKeyBanner && - (composioSortedEntries.length > 0 ? ( + // While the dynamic catalog is still being fetched and we + // have nothing real to show yet, render a loading skeleton + // instead of the hardcoded toolkit list. The hardcoded + // KNOWN_COMPOSIO_TOOLKITS list is only used as a post-fetch + // fallback (see composioCatalogToolkits), never during the + // in-flight loading window (#3933). + (composioLoading && composioSortedEntries.length === 0 ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( + + ) : composioSortedEntries.length > 0 ? (
({ composioConnectionsByToolkitOverride ?? new Map(Array.from(composioConnectionByToolkit.entries()).map(([k, v]) => [k, [v]])), refresh: composioRefresh, - loading: false, + loading: composioLoading, error: composioError, }), // Issue #2283 / CodeRabbit on #2361: Skills.tsx consumes @@ -72,6 +73,7 @@ describe('Skills page — Composio catalog fallback', () => { beforeEach(() => { composioRefresh = vi.fn(); composioError = null; + composioLoading = false; composioToolkits = []; composioCatalogByToolkit = new Map(); composioConnectionByToolkit = new Map(); @@ -142,6 +144,78 @@ describe('Skills page — Composio catalog fallback', () => { ).not.toBeInTheDocument(); }); + it('shows a loading skeleton (not the hardcoded list) while the catalog is still being fetched', () => { + // #3933: during the in-flight fetch we must NOT flash the hardcoded + // KNOWN_COMPOSIO_TOOLKITS list. Until the backend catalog lands the grid + // renders a loading skeleton instead. + composioLoading = true; + composioCatalogByToolkit = new Map(); + composioToolkits = []; + + renderWithProviders(, { initialEntries: ['/connections'] }); + openAppsTab(); + + const integrationsSection = screen.getByTestId('composio-integrations-card'); + // Loading skeleton is shown… + expect( + within(integrationsSection as HTMLElement).getByTestId('composio-integrations-loading') + ).toBeInTheDocument(); + expect( + within(integrationsSection as HTMLElement).queryAllByTestId('composio-skeleton-tile').length + ).toBeGreaterThan(0); + // …and none of the hardcoded fallback toolkits are rendered yet. + expect( + within(integrationsSection as HTMLElement).queryByText('Discord') + ).not.toBeInTheDocument(); + expect(within(integrationsSection as HTMLElement).queryByText('Gmail')).not.toBeInTheDocument(); + expect(within(integrationsSection as HTMLElement).queryByText('Slack')).not.toBeInTheDocument(); + }); + + it('falls back to the hardcoded list once the fetch finishes without a dynamic catalog', () => { + // The hardcoded list is a *post-fetch* fallback: when loading completes and + // the backend supplied no catalog (a failure or an older core), the grid + // must still render so it is never empty. + composioLoading = false; + composioCatalogByToolkit = new Map(); + + renderWithProviders(, { initialEntries: ['/connections'] }); + openAppsTab(); + + const integrationsSection = screen.getByTestId('composio-integrations-card'); + expect( + within(integrationsSection as HTMLElement).queryByTestId('composio-integrations-loading') + ).not.toBeInTheDocument(); + expect(within(integrationsSection as HTMLElement).getByText('Discord')).toBeInTheDocument(); + expect(within(integrationsSection as HTMLElement).getByText('Gmail')).toBeInTheDocument(); + }); + + it('shows the dynamic catalog (not the skeleton) even if a fetch is still in flight once entries exist', () => { + // Defensive: if catalog entries are already present we render them rather + // than the skeleton, even should `loading` still be true for a poll cycle. + composioLoading = true; + composioCatalogByToolkit = new Map([ + [ + 'gmail', + { slug: 'gmail', name: 'Gmail Dynamic', categories: ['productivity'], enabled: true }, + ], + ]); + + renderWithProviders(, { initialEntries: ['/connections'] }); + openAppsTab(); + + const integrationsSection = screen.getByTestId('composio-integrations-card'); + expect( + within(integrationsSection as HTMLElement).queryByTestId('composio-integrations-loading') + ).not.toBeInTheDocument(); + expect( + within(integrationsSection as HTMLElement).getByText('Gmail Dynamic') + ).toBeInTheDocument(); + // Hardcoded-only toolkit absent from the dynamic catalog stays hidden. + expect( + within(integrationsSection as HTMLElement).queryByText('Discord') + ).not.toBeInTheDocument(); + }); + it('shows a stale/error state instead of disconnected toolkits when composio loading fails', () => { composioError = 'Backend unavailable';