mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(composio): loading skeleton + bounded fetch timeout for Connections (no hardcoded flash) (#4079)
This commit is contained in:
@@ -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<ComposioToolkitsResponse> {
|
||||
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<ComposioToolkitsResponse> {
|
||||
const cached = readPersisted();
|
||||
if (cached && isFresh(cached)) return cached.response;
|
||||
|
||||
@@ -93,7 +100,7 @@ export async function getToolkitCatalog(): Promise<ComposioToolkitsResponse> {
|
||||
// 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -49,10 +49,51 @@ function unwrapCliEnvelope<T>(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<ComposioToolkitsResponse> {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.composio_list_toolkits' });
|
||||
export async function listToolkits(
|
||||
options?: ComposioReadOptions
|
||||
): Promise<ComposioToolkitsResponse> {
|
||||
const raw = await callCoreRpc<unknown>({
|
||||
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<ComposioToolkitsResponse>(raw);
|
||||
}
|
||||
|
||||
@@ -74,8 +115,17 @@ export async function listAgentReadyToolkits(): Promise<ComposioAgentReadyToolki
|
||||
return unwrapCliEnvelope<ComposioAgentReadyToolkitsResponse>(raw);
|
||||
}
|
||||
|
||||
export async function listConnections(): Promise<ComposioConnectionsResponse> {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.composio_list_connections' });
|
||||
export async function listConnections(
|
||||
options?: ComposioReadOptions
|
||||
): Promise<ComposioConnectionsResponse> {
|
||||
const raw = await callCoreRpc<unknown>({
|
||||
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<ComposioConnectionsResponse>(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -479,6 +479,7 @@ const messages: TranslationMap = {
|
||||
'skills.title': 'الاتصالات',
|
||||
'skills.search': 'البحث في الاتصالات...',
|
||||
'skills.noResults': 'لم يتم العثور على اتصالات',
|
||||
'skills.loadingIntegrations': 'جارٍ تحميل التكاملات…',
|
||||
'skills.connect': 'توصيل',
|
||||
'skills.disconnect': 'قطع الاتصال',
|
||||
'skills.configure': 'إدارة',
|
||||
|
||||
@@ -487,6 +487,7 @@ const messages: TranslationMap = {
|
||||
'skills.title': 'সংযোগ',
|
||||
'skills.search': 'সংযোগ খুঁজুন...',
|
||||
'skills.noResults': 'কোনো সংযোগ পাওয়া যায়নি',
|
||||
'skills.loadingIntegrations': 'ইন্টিগ্রেশন লোড হচ্ছে…',
|
||||
'skills.connect': 'সংযুক্ত করুন',
|
||||
'skills.disconnect': 'সংযোগ বিচ্ছিন্ন',
|
||||
'skills.configure': 'পরিচালনা',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -486,6 +486,7 @@ const messages: TranslationMap = {
|
||||
'skills.title': 'कनेक्शन',
|
||||
'skills.search': 'कनेक्शन सर्च करें...',
|
||||
'skills.noResults': 'कोई कनेक्शन नहीं मिला',
|
||||
'skills.loadingIntegrations': 'इंटीग्रेशन लोड हो रहे हैं…',
|
||||
'skills.connect': 'कनेक्ट करें',
|
||||
'skills.disconnect': 'डिसकनेक्ट करें',
|
||||
'skills.configure': 'मैनेज करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -486,6 +486,7 @@ const messages: TranslationMap = {
|
||||
'skills.title': '연결',
|
||||
'skills.search': '연결 검색...',
|
||||
'skills.noResults': '연결을 찾을 수 없습니다',
|
||||
'skills.loadingIntegrations': '통합을 불러오는 중…',
|
||||
'skills.connect': '연결',
|
||||
'skills.disconnect': '연결 해제',
|
||||
'skills.configure': '관리',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -493,6 +493,7 @@ const messages: TranslationMap = {
|
||||
'skills.title': 'Подключения',
|
||||
'skills.search': 'Поиск подключений...',
|
||||
'skills.noResults': 'Подключения не найдены',
|
||||
'skills.loadingIntegrations': 'Загрузка интеграций…',
|
||||
'skills.connect': 'Подключить',
|
||||
'skills.disconnect': 'Отключить',
|
||||
'skills.configure': 'Управление',
|
||||
|
||||
@@ -469,6 +469,7 @@ const messages: TranslationMap = {
|
||||
'skills.title': '连接',
|
||||
'skills.search': '搜索连接...',
|
||||
'skills.noResults': '未找到连接',
|
||||
'skills.loadingIntegrations': '正在加载集成…',
|
||||
'skills.connect': '连接',
|
||||
'skills.disconnect': '断开',
|
||||
'skills.configure': '配置',
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
{!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 ? (
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
data-testid="composio-integrations-loading"
|
||||
role="status"
|
||||
aria-label={t('skills.loadingIntegrations')}
|
||||
aria-busy="true"
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))',
|
||||
gridAutoRows: '6.5rem',
|
||||
}}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="composio-skeleton-tile"
|
||||
aria-hidden="true"
|
||||
className="animate-pulse rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-100 dark:bg-neutral-800/60"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : composioSortedEntries.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{
|
||||
|
||||
@@ -7,6 +7,7 @@ import Skills from '../Skills';
|
||||
|
||||
let composioRefresh = vi.fn();
|
||||
let composioError: string | null = null;
|
||||
let composioLoading = false;
|
||||
let composioToolkits: string[] = [];
|
||||
let composioCatalogByToolkit = new Map();
|
||||
let composioConnectionByToolkit = new Map();
|
||||
@@ -44,7 +45,7 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user