feat(skills): hide preview Composio toolkits by default + add Preview filter pill (#2711)

This commit is contained in:
Mega Mind
2026-05-27 02:56:20 +05:30
committed by GitHub
parent 77c15cb868
commit 6d0657e4c0
4 changed files with 312 additions and 7 deletions
+3 -1
View File
@@ -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',
];
+7
View File
@@ -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({
+58 -6
View File
@@ -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();
@@ -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<string>; loading: boolean; error: string | null } = {
agentReady: new Set<string>(),
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<typeof import('../../lib/coreState/store')>(
'../../lib/coreState/store'
);
return { ...actual, getCoreStateSnapshot: () => ({ snapshot: { sessionToken } }) };
});
vi.mock('../../utils/tauriCommands', async () => {
const actual = await vi.importActual<typeof import('../../utils/tauriCommands')>(
'../../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-<slug>"` 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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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<string>;
agentReadyState = { agentReady: allAgentReady, loading: false, error: null };
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
expect(screen.queryByRole('tab', { name: /Preview/i })).not.toBeInTheDocument();
});
});