From 6d0657e4c06d0322222bcc1ed0b6bcac1a2db8a1 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 27 May 2026 02:56:20 +0530 Subject: [PATCH] feat(skills): hide preview Composio toolkits by default + add Preview filter pill (#2711) --- app/src/components/skills/skillCategories.ts | 4 +- app/src/components/skills/skillIcons.tsx | 7 + app/src/pages/Skills.tsx | 64 ++++- .../__tests__/Skills.preview-filter.test.tsx | 244 ++++++++++++++++++ 4 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 app/src/pages/__tests__/Skills.preview-filter.test.tsx diff --git a/app/src/components/skills/skillCategories.ts b/app/src/components/skills/skillCategories.ts index 865381736..efea6f853 100644 --- a/app/src/components/skills/skillCategories.ts +++ b/app/src/components/skills/skillCategories.ts @@ -7,7 +7,8 @@ export type SkillCategory = | 'Tools & Automation' | 'Social' | 'Platform' - | 'Other'; + | 'Other' + | 'Preview'; export const SKILL_CATEGORY_ORDER: SkillCategory[] = [ 'All', @@ -19,4 +20,5 @@ export const SKILL_CATEGORY_ORDER: SkillCategory[] = [ 'Social', 'Platform', 'Other', + 'Preview', ]; diff --git a/app/src/components/skills/skillIcons.tsx b/app/src/components/skills/skillIcons.tsx index 8791fe7e9..021d62433 100644 --- a/app/src/components/skills/skillIcons.tsx +++ b/app/src/components/skills/skillIcons.tsx @@ -7,6 +7,7 @@ import YuanbaoIcon from '../channels/YuanbaoIcon'; import { LuBlocks, LuBot, + LuEye, LuKeyboard, LuMessageSquareMore, LuMic, @@ -155,6 +156,12 @@ const CATEGORY_META: Record< iconClassName: 'text-stone-500 dark:text-neutral-400', headingClassName: 'text-stone-500 dark:text-neutral-400', }, + Preview: { + icon: LuEye, + chipClassName: 'bg-amber-50 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200', + iconClassName: 'text-amber-600 dark:text-amber-300', + headingClassName: 'text-amber-600 dark:text-amber-300', + }, }; export function SkillCategoryIcon({ diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 111d63a23..0546a01c4 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -658,17 +658,56 @@ export default function Skills() { return entries; }, [composioCatalogToolkits, composioConnectionByToolkit]); + // Exclude preview toolkits (not in the agent-ready catalog) from the default + // grid. Toolkits with an existing connection are always shown so users can + // manage or disconnect them. The filter is skipped while the agent-ready list + // is loading or errored — both cases degrade to showing everything. + // When the user selects the 'Preview' filter pill this memo is bypassed in + // composioFilteredEntries, which reads directly from composioGridEntries. + const composioAgentReadyEntries = useMemo(() => { + const resolved = !agentReadyLoading && !agentReadyError; + if (!resolved) return composioGridEntries; + return composioGridEntries.filter( + ({ meta, connection }) => Boolean(connection) || agentReadyToolkits.has(meta.slug) + ); + }, [composioGridEntries, agentReadyToolkits, agentReadyLoading, agentReadyError]); + const composioFilteredEntries = useMemo(() => { const q = searchQuery.toLowerCase(); + const matchesSearch = (meta: ComposioToolkitMeta) => + !q || meta.name.toLowerCase().includes(q) || meta.description.toLowerCase().includes(q); + + if (selectedCategory === 'Preview') { + // Show only toolkits that are not in the agent-ready catalog. + // If data is still loading or errored, show all matching entries so the + // grid doesn't flash blank — same graceful-degradation contract as the + // default view. + const resolved = !agentReadyLoading && !agentReadyError; + if (!resolved) { + return composioGridEntries.filter(({ meta }) => matchesSearch(meta)); + } + return composioGridEntries.filter( + ({ meta }) => matchesSearch(meta) && !agentReadyToolkits.has(meta.slug) + ); + } + const matchesCategory = selectedCategory === 'All' ? () => true : (meta: ComposioToolkitMeta) => meta.category === selectedCategory; - const matchesSearch = (meta: ComposioToolkitMeta) => - !q || meta.name.toLowerCase().includes(q) || meta.description.toLowerCase().includes(q); - return composioGridEntries.filter(({ meta }) => matchesCategory(meta) && matchesSearch(meta)); - }, [composioGridEntries, searchQuery, selectedCategory]); + return composioAgentReadyEntries.filter( + ({ meta }) => matchesCategory(meta) && matchesSearch(meta) + ); + }, [ + composioAgentReadyEntries, + composioGridEntries, + searchQuery, + selectedCategory, + agentReadyToolkits, + agentReadyLoading, + agentReadyError, + ]); const composioSortedEntries = useMemo(() => { return [...composioFilteredEntries].sort((a, b) => { @@ -696,11 +735,24 @@ export default function Skills() { if (item.category === 'Channels') continue; cats.add(item.category); } - for (const { meta } of composioGridEntries) { + for (const { meta } of composioAgentReadyEntries) { cats.add(meta.category); } + // Show the 'Preview' pill when there are toolkits outside the agent-ready + // catalog (i.e., there's something to see in that filter). + const resolved = !agentReadyLoading && !agentReadyError; + if (resolved && composioGridEntries.some(({ meta }) => !agentReadyToolkits.has(meta.slug))) { + cats.add('Preview'); + } return SKILL_CATEGORY_ORDER.filter(c => c !== 'Channels' && cats.has(c)); - }, [allItems, composioGridEntries]); + }, [ + allItems, + composioAgentReadyEntries, + composioGridEntries, + agentReadyToolkits, + agentReadyLoading, + agentReadyError, + ]); const filteredItems = useMemo(() => { const q = searchQuery.toLowerCase(); diff --git a/app/src/pages/__tests__/Skills.preview-filter.test.tsx b/app/src/pages/__tests__/Skills.preview-filter.test.tsx new file mode 100644 index 000000000..8710b753d --- /dev/null +++ b/app/src/pages/__tests__/Skills.preview-filter.test.tsx @@ -0,0 +1,244 @@ +/** + * Tests for the "Preview" composio filter pill introduced in issue #2283. + * + * Covers: + * - Default grid hides non-agent-ready toolkits that have no connection. + * - Toolkits with an existing connection always appear in the default grid. + * - The "Preview" pill reveals non-agent-ready toolkits and hides curated ones. + * - While agent-ready data is loading, all toolkits are shown (no blank flash). + * - On agent-ready fetch error, all toolkits are shown (graceful degradation). + * - Search works correctly in both default and Preview modes. + * - The "Preview" pill is absent when every toolkit is agent-ready. + */ +import { fireEvent, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import '../../test/mockDefaultSkillStatusHooks'; +import { renderWithProviders } from '../../test/test-utils'; +import Skills from '../Skills'; + +// ── Mutable state shared across tests ───────────────────────────────────────── + +let composioToolkits: string[] = []; +let composioConnectionByToolkit = new Map< + string, + { id: string; toolkit: string; status: string } +>(); +let agentReadyState: { agentReady: Set; loading: boolean; error: string | null } = { + agentReady: new Set(), + loading: false, + error: null, +}; +let composioModeStatus = { result: { mode: 'backend', api_key_set: true }, logs: [] }; +let sessionToken = 'jwt-abc'; + +// ── Mocks (module-level, hoisted by Vitest) ──────────────────────────────────── + +vi.mock('../../hooks/useChannelDefinitions', () => ({ + useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }), +})); + +vi.mock('../../lib/skills/skillsApi', () => ({ + installSkill: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../lib/skills/hooks', () => ({ + useAvailableSkills: () => ({ skills: [], loading: false, refresh: vi.fn() }), +})); + +vi.mock('../../lib/composio/hooks', () => ({ + useComposioIntegrations: () => ({ + toolkits: composioToolkits, + connectionByToolkit: composioConnectionByToolkit, + refresh: vi.fn(), + loading: false, + error: null, + }), + useAgentReadyComposioToolkits: () => agentReadyState, +})); + +vi.mock('../../lib/coreState/store', async () => { + const actual = await vi.importActual( + '../../lib/coreState/store' + ); + return { ...actual, getCoreStateSnapshot: () => ({ snapshot: { sessionToken } }) }; +}); + +vi.mock('../../utils/tauriCommands', async () => { + const actual = await vi.importActual( + '../../utils/tauriCommands' + ); + return { + ...actual, + openhumanComposioGetMode: vi.fn(async () => composioModeStatus), + subconsciousEscalationsDismiss: vi.fn(), + }; +}); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Returns the integrations section container element. */ +function getIntegrationsSection(): HTMLElement { + const heading = screen.getByRole('heading', { name: 'Composio Integrations' }); + const section = heading.closest('.rounded-2xl'); + expect(section).not.toBeNull(); + return section as HTMLElement; +} + +/** Queries all `data-testid="skill-row-composio-"` within an element. */ +function getComposioSlugs(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-testid^="skill-row-composio-"]')).map(el => + (el as HTMLElement).dataset.testid!.replace('skill-row-composio-', '') + ); +} + +// ── Setup ────────────────────────────────────────────────────────────────────── + +describe('Skills page — Preview filter pill', () => { + beforeEach(() => { + composioToolkits = []; + composioConnectionByToolkit = new Map(); + composioModeStatus = { result: { mode: 'backend', api_key_set: true }, logs: [] }; + sessionToken = 'jwt-abc'; + // Resolved state with two agent-ready toolkits and two preview-only ones. + agentReadyState = { agentReady: new Set(['gmail', 'github']), loading: false, error: null }; + }); + + // ── Test 1 ────────────────────────────────────────────────────────────────── + it('default grid shows agent-ready toolkits and hides non-agent-ready unconnected ones', () => { + renderWithProviders(, { initialEntries: ['/skills'] }); + const section = getIntegrationsSection(); + const slugs = getComposioSlugs(section); + + expect(slugs).toContain('gmail'); + expect(slugs).toContain('github'); + // airtable is in KNOWN_COMPOSIO_TOOLKITS but NOT agent-ready and has no + // connection — it must be hidden from the default view. + expect(slugs).not.toContain('airtable'); + }); + + // ── Test 2 ────────────────────────────────────────────────────────────────── + it('default grid always shows a connected toolkit even if it is not agent-ready', () => { + // notion is NOT in the agent-ready set but has an active connection. + composioConnectionByToolkit = new Map([ + ['notion', { id: 'ca_notion', toolkit: 'notion', status: 'ACTIVE' }], + ]); + + renderWithProviders(, { initialEntries: ['/skills'] }); + const section = getIntegrationsSection(); + const slugs = getComposioSlugs(section); + + expect(slugs).toContain('notion'); + expect(slugs).not.toContain('airtable'); + }); + + // ── Test 3 ────────────────────────────────────────────────────────────────── + it('Preview pill appears and shows only non-agent-ready toolkits when selected', () => { + renderWithProviders(, { initialEntries: ['/skills'] }); + + // The Preview pill must be visible. + const previewTab = screen.getByRole('tab', { name: /Preview/i }); + expect(previewTab).toBeInTheDocument(); + + fireEvent.click(previewTab); + + const section = getIntegrationsSection(); + const slugs = getComposioSlugs(section); + + // Agent-ready toolkits must not appear in Preview mode. + expect(slugs).not.toContain('gmail'); + expect(slugs).not.toContain('github'); + // A non-agent-ready toolkit (airtable) must appear. + expect(slugs).toContain('airtable'); + }); + + // ── Test 4 ────────────────────────────────────────────────────────────────── + it('while agent-ready data is loading, all toolkits are shown in the default view', () => { + agentReadyState = { agentReady: new Set(), loading: true, error: null }; + + renderWithProviders(, { initialEntries: ['/skills'] }); + const section = getIntegrationsSection(); + const slugs = getComposioSlugs(section); + + // Non-agent-ready toolkit must still be visible (no flash of empty grid). + expect(slugs).toContain('airtable'); + expect(slugs).toContain('gmail'); + }); + + // ── Test 5 ────────────────────────────────────────────────────────────────── + it('while agent-ready data is loading, Preview pill is hidden and integrations grid remains populated', () => { + agentReadyState = { agentReady: new Set(), loading: true, error: null }; + + renderWithProviders(, { initialEntries: ['/skills'] }); + + // Preview pill must not appear until agent-ready data resolves. + expect(screen.queryByRole('tab', { name: /Preview/i })).not.toBeInTheDocument(); + // Default grid must remain non-empty (no flash of blank grid while loading). + const section = getIntegrationsSection(); + expect(getComposioSlugs(section).length).toBeGreaterThan(0); + }); + + // ── Test 6 ────────────────────────────────────────────────────────────────── + it('on agent-ready fetch error, all toolkits are shown (graceful degradation)', () => { + agentReadyState = { agentReady: new Set(), loading: false, error: 'rpc unavailable' }; + + renderWithProviders(, { initialEntries: ['/skills'] }); + const section = getIntegrationsSection(); + const slugs = getComposioSlugs(section); + + expect(slugs).toContain('airtable'); + expect(slugs).toContain('gmail'); + // No Preview badges — we cannot tell which toolkits are non-agent-ready. + expect(within(section).queryAllByTestId(/composio-preview-badge-/)).toHaveLength(0); + }); + + // ── Test 7 ────────────────────────────────────────────────────────────────── + it('search query filters results in default mode', () => { + renderWithProviders(, { initialEntries: ['/skills'] }); + + const searchInput = screen.getByPlaceholderText('Search skills…'); + fireEvent.change(searchInput, { target: { value: 'gmail' } }); + + const section = getIntegrationsSection(); + const slugs = getComposioSlugs(section); + + expect(slugs).toContain('gmail'); + expect(slugs).not.toContain('github'); + }); + + // ── Test 8 ────────────────────────────────────────────────────────────────── + it('search query filters results in Preview mode', () => { + renderWithProviders(, { initialEntries: ['/skills'] }); + + const previewTab = screen.getByRole('tab', { name: /Preview/i }); + fireEvent.click(previewTab); + + const searchInput = screen.getByPlaceholderText('Search skills…'); + fireEvent.change(searchInput, { target: { value: 'airtable' } }); + + const section = getIntegrationsSection(); + const slugs = getComposioSlugs(section); + + expect(slugs).toContain('airtable'); + // Other non-agent-ready toolkits filtered out by search. + expect(slugs).not.toContain('notion'); + }); + + // ── Test 9 ────────────────────────────────────────────────────────────────── + it('Preview pill does not appear when every catalog toolkit is agent-ready', () => { + // Mark every KNOWN toolkit as agent-ready by providing a wildcard check. + // We achieve this by setting agentReady to a very large set — in practice + // we make loading=false, error=null, and every toolkit appear in agentReady + // by stubbing it with a custom has() implementation. + const allAgentReady = { + has: () => true, + *[Symbol.iterator]() {}, + size: 999, + } as unknown as Set; + agentReadyState = { agentReady: allAgentReady, loading: false, error: null }; + + renderWithProviders(, { initialEntries: ['/skills'] }); + + expect(screen.queryByRole('tab', { name: /Preview/i })).not.toBeInTheDocument(); + }); +});