feat(skills): channels grid + integrations card polish; tolerant Composio trigger decode (#1112)

This commit is contained in:
Steven Enamakel
2026-05-02 20:40:14 -07:00
committed by GitHub
parent 195c9c51a6
commit 07b02d5e6c
12 changed files with 725 additions and 315 deletions
Generated
+1 -1
View File
@@ -4568,7 +4568,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.53.10"
version = "0.53.11"
dependencies = [
"aes-gcm",
"anyhow",
+2 -2
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "OpenHuman"
version = "0.53.4"
version = "0.53.11"
dependencies = [
"anyhow",
"async-trait",
@@ -4522,7 +4522,7 @@ dependencies = [
[[package]]
name = "openhuman"
version = "0.53.4"
version = "0.53.11"
dependencies = [
"aes-gcm",
"anyhow",
@@ -316,12 +316,6 @@ export default function ComposioConnectModal({
<h2 id="composio-setup-title" className="text-base font-semibold text-stone-900">
{headerTitle}
</h2>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-primary-500/15 text-primary-600">
composio
</span>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-sky-500/10 text-sky-700">
managed auth
</span>
</div>
<p className="text-xs text-stone-400 mt-1.5 line-clamp-2">{toolkit.description}</p>
</div>
@@ -347,14 +341,10 @@ export default function ComposioConnectModal({
{phase === 'idle' && (
<>
<p className="text-sm text-stone-600">
Connect your {toolkit.name} account through Composio&apos;s hosted authorization
flow. We&apos;ll open a browser window, you approve access there, and this app will
detect the connection automatically.
Connect your {toolkit.name} account. We&apos;ll open a browser window, you approve
access there, and this app will detect the connection automatically.
</p>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-stone-500">
Provider access
</p>
<p className="mt-1 text-xs leading-relaxed text-stone-600">
{toolkit.name} can expose{' '}
<span className="font-medium">{toolkit.permissionLabel}</span>. After you connect,
@@ -501,7 +491,7 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
<div className="border-t border-stone-100 pt-3 mt-1 space-y-2">
<div className="flex items-baseline justify-between">
<h3 className="text-xs font-semibold text-stone-700 uppercase tracking-wide">
OpenHuman agent permissions
Permissions
</h3>
<p className="text-[10px] text-stone-400">Read + Write enabled by default</p>
</div>
+2 -2
View File
@@ -143,7 +143,7 @@ const MANAGED_COMPOSIO_TOOLKITS: readonly ManagedToolkitEntry[] = Object.freeze(
{ slug: 'typeform', name: 'Typeform' },
{ slug: 'wakatime', name: 'WakaTime' },
{ slug: 'webex', name: 'Webex' },
{ slug: 'whatsapp', name: 'WhatsApp' },
{ slug: 'whatsapp', name: 'WhatsApp Business' },
{ slug: 'wrike', name: 'Wrike' },
{ slug: 'yandex', name: 'Yandex' },
{ slug: 'ynab', name: 'YNAB' },
@@ -286,7 +286,7 @@ function defaultDescription(name: string, category: SkillCategory): string {
case 'Platform':
return `Connect ${name} for developer, platform, CRM, and business system workflows.`;
default:
return `Connect ${name} through Composio managed auth.`;
return `Connect ${name}.`;
}
}
+9
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import type { IconType } from 'react-icons';
import { FaDiscord, FaGlobe, FaTelegramPlane } from 'react-icons/fa';
import { IoChatbubble } from 'react-icons/io5';
import {
LuBlocks,
LuBot,
@@ -72,6 +73,14 @@ export const CHANNEL_ICONS: Record<string, ReactNode> = {
iconClassName="text-stone-600"
/>
),
imessage: (
<SkillIconBadge
icon={IoChatbubble}
label="iMessage"
bgClassName="bg-[#E8F8EE]"
iconClassName="text-[#34C759]"
/>
),
};
const CATEGORY_META: Record<
+4 -4
View File
@@ -77,24 +77,24 @@ export function useUsageState(): UsageState {
const isFreeTier = currentTier === 'FREE';
const usagePct10h =
teamUsage && teamUsage.fiveHourCapUsd > 0
teamUsage && teamUsage.fiveHourCapUsd > 0.01
? Math.min(1, teamUsage.cycleLimit5hr / teamUsage.fiveHourCapUsd)
: 0;
const usagePct7d =
teamUsage && teamUsage.cycleBudgetUsd > 0
teamUsage && teamUsage.cycleBudgetUsd > 0.01
? Math.min(1, (teamUsage.cycleBudgetUsd - teamUsage.remainingUsd) / teamUsage.cycleBudgetUsd)
: 0;
const isBudgetExhausted = teamUsage
? teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0
? teamUsage.cycleBudgetUsd > 0.01 && teamUsage.remainingUsd <= 0.01
: false;
// Some users have no included recurring budget at all. They still need the
// completed-budget warning in chat even though they are not in an exhausted
// paid cycle.
const shouldShowBudgetCompletedMessage = teamUsage
? isBudgetExhausted || (teamUsage.cycleBudgetUsd <= 0 && teamUsage.remainingUsd <= 0)
? isBudgetExhausted || (teamUsage.cycleBudgetUsd <= 0.01 && teamUsage.remainingUsd <= 0.01)
: false;
const isRateLimited =
+1 -1
View File
@@ -45,7 +45,7 @@ const Home = () => {
const promoCredits = user?.usage?.promotionBalanceUsd ?? 0;
const isFreeTier =
user?.subscription?.plan === 'FREE' || !user?.subscription?.hasActiveSubscription;
const showPromoBanner = isFreeTier && promoCredits > 0;
const showPromoBanner = isFreeTier && promoCredits > 0.01;
const welcomeVariants = useMemo(
() => [`Welcome, ${userName} 👋`, `Let's cook, ${userName} 🧑‍🍳.`, `Time to Zone In 🧘🏻`],
+313 -287
View File
@@ -8,7 +8,6 @@ import {
type ComposioToolkitMeta,
KNOWN_COMPOSIO_TOOLKITS,
} from '../components/composio/toolkitMeta';
import ConnectionBadge, { isMessagingId } from '../components/ConnectionBadge';
import { ToastContainer } from '../components/intelligence/Toast';
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
import CreateSkillModal from '../components/skills/CreateSkillModal';
@@ -41,19 +40,6 @@ import type { ToastNotification } from '../types/intelligence';
import { IS_DEV } from '../utils/config';
import { subconsciousEscalationsDismiss } from '../utils/tauriCommands';
function channelStatusDot(status: ChannelConnectionStatus): string {
switch (status) {
case 'connected':
return 'bg-sage-500';
case 'connecting':
return 'bg-amber-500 animate-pulse';
case 'error':
return 'bg-coral-500';
default:
return 'bg-stone-300';
}
}
function channelStatusLabel(status: ChannelConnectionStatus): string {
switch (status) {
case 'connected':
@@ -84,19 +70,6 @@ function channelStatusColor(status: ChannelConnectionStatus): string {
// Reuse the same dot/label/color vocabulary as the channel cards so the
// "Integrations" section sits visually flush with the rest of the grid.
function composioStatusDot(connection: ComposioConnection | undefined): string {
switch (deriveComposioState(connection)) {
case 'connected':
return 'bg-sage-500';
case 'pending':
return 'bg-amber-500 animate-pulse';
case 'error':
return 'bg-coral-500';
default:
return 'bg-stone-300';
}
}
function composioStatusLabel(connection: ComposioConnection | undefined): string {
switch (deriveComposioState(connection)) {
case 'connected':
@@ -106,7 +79,7 @@ function composioStatusLabel(connection: ComposioConnection | undefined): string
case 'error':
return 'Error';
default:
return 'Not connected';
return '';
}
}
@@ -182,7 +155,7 @@ function ComposioConnectorTile({
onClick={handleClick}
title={`${meta.name}${meta.description}`}
aria-label={`${meta.name}, ${statusLabel}. ${ctaLabel}.`}
className={`group flex flex-col items-center gap-2 rounded-2xl border p-3 pb-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${
className={`group flex flex-col justify-center items-center rounded-2xl border p-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${
isConnected
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50'
: isPending
@@ -193,14 +166,8 @@ function ComposioConnectorTile({
}`}>
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center text-stone-700 [&_img]:max-h-10 [&_img]:max-w-10 [&_svg]:h-8 [&_svg]:w-8">
{meta.icon}
<span
className={`absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-white ${
hasComposioError ? 'bg-amber-500' : composioStatusDot(connection)
}`}
aria-hidden
/>
</div>
<div className="flex min-h-[2.5rem] w-full min-w-0 flex-col items-center justify-start gap-0.5">
<div className="flex w-full min-w-0 flex-col items-center justify-start gap-0.5">
<span className="line-clamp-2 text-[11px] font-semibold leading-tight text-stone-900">
{meta.name}
</span>
@@ -215,6 +182,50 @@ function ComposioConnectorTile({
);
}
interface ChannelTileProps {
def: ChannelDefinition;
status: ChannelConnectionStatus;
icon: React.ReactNode;
onOpen: () => void;
}
function ChannelTile({ def, status, icon, onOpen }: ChannelTileProps) {
const isConnected = status === 'connected';
const isPending = status === 'connecting';
const isError = status === 'error';
const statusLabel = channelStatusLabel(status);
const ctaLabel = isConnected ? 'Manage' : 'Setup';
return (
<button
type="button"
onClick={onOpen}
title={`${def.display_name}${def.description}`}
aria-label={`${def.display_name}, ${statusLabel}. ${ctaLabel}.`}
className={`group flex flex-col items-center gap-2 rounded-2xl border p-3 pb-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${
isConnected
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50'
: isPending
? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70'
: isError
? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50'
: 'border-stone-200 bg-white hover:bg-stone-50'
}`}>
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center text-stone-700 [&>span]:h-12 [&>span]:w-12 [&>span]:rounded-2xl [&_svg]:h-7 [&_svg]:w-7">
{icon}
</div>
<div className="flex min-h-[2.5rem] w-full min-w-0 flex-col items-center justify-start gap-0.5">
<span className="line-clamp-2 text-[11px] font-semibold leading-tight text-stone-900">
{def.display_name}
</span>
<span className={`line-clamp-1 text-[10px] font-medium ${channelStatusColor(status)}`}>
{statusLabel}
</span>
</div>
</button>
);
}
// ─── Built-in skill definitions ────────────────────────────────────────────────
const BUILT_IN_SKILLS: Array<{
@@ -499,12 +510,13 @@ export default function Skills() {
const availableCategories: SkillCategory[] = useMemo(() => {
const cats = new Set<SkillCategory>(['All']);
for (const item of allItems) {
if (item.category === 'Channels') continue;
cats.add(item.category);
}
for (const { meta } of composioGridEntries) {
cats.add(meta.category);
}
return SKILL_CATEGORY_ORDER.filter(c => cats.has(c));
return SKILL_CATEGORY_ORDER.filter(c => c !== 'Channels' && cats.has(c));
}, [allItems, composioGridEntries]);
const filteredItems = useMemo(() => {
@@ -530,12 +542,201 @@ export default function Skills() {
return Array.from(groups.entries()).map(([category, items]) => ({ category, items }));
}, [filteredItems]);
const channelsGroup = useMemo(() => {
const items = allItems.filter(item => item.category === 'Channels');
return items.length > 0 ? { category: 'Channels' as SkillCategory, items } : undefined;
}, [allItems]);
const otherGroups = useMemo(
() => groupedItems.filter(g => g.category !== 'Channels'),
[groupedItems]
);
const renderGroup = ({ category, items }: { category: SkillCategory; items: SkillItem[] }) => (
<div
key={category}
className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<h2 className="flex items-center gap-2 text-sm font-semibold text-stone-900">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-stone-100">
<SkillCategoryIcon
category={category}
className={skillCategoryHeadingClassName(category)}
/>
</span>
{category}
</h2>
</div>
<div className="space-y-2">
{items.map(item => {
if (item.kind === 'builtin') {
/* v8 ignore start -- BUILT_IN_SKILLS list is empty today; the per-id
branches below are kept for re-enabling screen-intelligence /
text-autocomplete / voice-stt and shouldn't drag the diff-coverage
gate down while they're unreachable. */
if (item.id === 'screen-intelligence') {
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusLabel={screenIntelligenceStatus.statusLabel}
statusColor={screenIntelligenceStatus.statusColor}
ctaLabel={screenIntelligenceStatus.ctaLabel}
ctaVariant={screenIntelligenceStatus.ctaVariant}
onCtaClick={() => {
if (screenIntelligenceStatus.platformUnsupported) {
navigate(item.route!);
return;
}
if (
screenIntelligenceStatus.connectionStatus === 'connected' ||
screenIntelligenceStatus.connectionStatus === 'disconnected'
) {
navigate(item.route!);
return;
}
setScreenIntelligenceModalOpen(true);
}}
/>
);
}
if (item.id === 'text-autocomplete') {
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusLabel={autocompleteStatus.statusLabel}
statusColor={autocompleteStatus.statusColor}
ctaLabel={autocompleteStatus.ctaLabel}
ctaVariant={autocompleteStatus.ctaVariant}
onCtaClick={() => {
if (
autocompleteStatus.platformUnsupported ||
autocompleteStatus.connectionStatus === 'connected' ||
autocompleteStatus.connectionStatus === 'disconnected'
) {
navigate(item.route!);
return;
}
setAutocompleteModalOpen(true);
}}
/>
);
}
if (item.id === 'voice-stt') {
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusLabel={voiceStatus.statusLabel}
statusColor={voiceStatus.statusColor}
ctaLabel={voiceStatus.ctaLabel}
ctaVariant={voiceStatus.ctaVariant}
onCtaClick={() => {
if (
voiceStatus.connectionStatus === 'connected' ||
voiceStatus.connectionStatus === 'connecting' ||
voiceStatus.connectionStatus === 'disconnected'
) {
navigate(item.route!);
return;
}
setVoiceModalOpen(true);
}}
/>
);
}
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
ctaLabel="Settings"
onCtaClick={() => navigate(item.route!)}
/>
);
/* v8 ignore stop */
}
if (item.kind === 'discovered') {
const skill = item.discoveredSkill!;
const scopeLabel = skill.legacy
? 'Legacy'
: skill.scope === 'user'
? 'User'
: skill.scope === 'project'
? 'Project'
: 'Legacy';
const scopeColor = skill.legacy
? 'text-stone-600'
: skill.scope === 'user'
? 'text-sage-600'
: skill.scope === 'project'
? 'text-amber-600'
: 'text-stone-600';
const canUninstall = skill.scope === 'user' && !skill.legacy;
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusLabel={scopeLabel}
statusColor={scopeColor}
ctaLabel="View"
onCtaClick={() => {
console.debug('[skills][discovered] open drawer', { skillId: skill.id });
setSelectedSkill(skill);
}}
secondaryActions={
canUninstall
? [
{
label: 'Uninstall',
testId: `uninstall-skill-${skill.id}`,
icon: (
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14z"
/>
</svg>
),
onClick: () => {
console.debug('[skills][discovered] open uninstall', {
skillId: skill.id,
});
setUninstallCandidate(skill);
},
},
]
: undefined
}
/>
);
}
})}
</div>
</div>
);
return (
<div className="min-h-full">
<div className="min-h-full flex flex-col">
<div className="flex-1 flex items-start justify-center p-4 pt-6">
<div className="w-full max-w-4xl space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="w-full max-w-3xl space-y-4">
{/* <div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="text-base font-semibold text-stone-900">Skills</h1>
<p className="text-xs text-stone-500">
@@ -557,15 +758,7 @@ export default function Skills() {
New skill
</button>
</div>
</div>
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
<SkillCategoryFilter
categories={availableCategories}
selected={selectedCategory}
onChange={setSelectedCategory}
/>
</div> */}
{composioError && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-3 shadow-soft">
@@ -586,249 +779,82 @@ export default function Skills() {
</div>
)}
{composioSortedEntries.length > 0 && (
<div className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<h2 className="text-sm font-semibold text-stone-900">Integrations</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500">
Connect external apps. Connected services are sorted first and highlighted in
green.
</p>
</div>
<div
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{composioSortedEntries.map(({ meta, connection }) => (
<ComposioConnectorTile
key={meta.slug}
meta={meta}
connection={connection}
hasComposioError={Boolean(composioError)}
onOpen={() => setComposioModalToolkit(meta)}
onRetryGlobal={() => void refreshComposio()}
/>
))}
</div>
</div>
)}
{filteredItems.length === 0 && composioSortedEntries.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-stone-400">No connections found</p>
</div>
) : (
groupedItems.map(({ category, items }) => (
<div
key={category}
className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<h2 className="flex items-center gap-2 text-sm font-semibold text-stone-900">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-stone-100">
<SkillCategoryIcon
category={category}
className={skillCategoryHeadingClassName(category)}
{
<>
{channelsGroup && (
<div className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<h2 className="flex items-center gap-2 text-sm font-semibold text-stone-900">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-stone-100">
<SkillCategoryIcon
category="Channels"
className={skillCategoryHeadingClassName('Channels')}
/>
</span>
Channels
</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500">
Connect messaging platforms so your assistant can chat where your community
already lives.
</p>
</div>
<div
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{channelsGroup.items.map(item => (
<ChannelTile
key={item.id}
def={item.channelDef!}
status={item.channelStatus!}
icon={item.icon}
onOpen={() => setChannelModalDef(item.channelDef!)}
/>
</span>
{category}
</h2>
))}
</div>
</div>
<div className="space-y-2">
{items.map(item => {
if (item.kind === 'builtin') {
// Screen Intelligence gets a state-aware card
if (item.id === 'screen-intelligence') {
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusDot={screenIntelligenceStatus.statusDot}
statusLabel={screenIntelligenceStatus.statusLabel}
statusColor={screenIntelligenceStatus.statusColor}
ctaLabel={screenIntelligenceStatus.ctaLabel}
ctaVariant={screenIntelligenceStatus.ctaVariant}
onCtaClick={() => {
if (screenIntelligenceStatus.platformUnsupported) {
navigate(item.route!);
return;
}
if (
screenIntelligenceStatus.connectionStatus === 'connected' ||
screenIntelligenceStatus.connectionStatus === 'disconnected'
) {
navigate(item.route!);
return;
}
setScreenIntelligenceModalOpen(true);
}}
/>
);
}
// Text Auto-Complete gets a state-aware card
if (item.id === 'text-autocomplete') {
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusDot={autocompleteStatus.statusDot}
statusLabel={autocompleteStatus.statusLabel}
statusColor={autocompleteStatus.statusColor}
ctaLabel={autocompleteStatus.ctaLabel}
ctaVariant={autocompleteStatus.ctaVariant}
onCtaClick={() => {
if (
autocompleteStatus.platformUnsupported ||
autocompleteStatus.connectionStatus === 'connected' ||
autocompleteStatus.connectionStatus === 'disconnected'
) {
navigate(item.route!);
return;
}
setAutocompleteModalOpen(true);
}}
/>
);
}
// Voice Intelligence gets a state-aware card
if (item.id === 'voice-stt') {
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusDot={voiceStatus.statusDot}
statusLabel={voiceStatus.statusLabel}
statusColor={voiceStatus.statusColor}
ctaLabel={voiceStatus.ctaLabel}
ctaVariant={voiceStatus.ctaVariant}
onCtaClick={() => {
if (
voiceStatus.connectionStatus === 'connected' ||
voiceStatus.connectionStatus === 'connecting' ||
voiceStatus.connectionStatus === 'disconnected'
) {
navigate(item.route!);
return;
}
setVoiceModalOpen(true);
}}
/>
);
}
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
ctaLabel="Settings"
onCtaClick={() => navigate(item.route!)}
/>
);
}
if (item.kind === 'channel') {
const status = item.channelStatus!;
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusDot={channelStatusDot(status)}
statusLabel={channelStatusLabel(status)}
statusColor={channelStatusColor(status)}
ctaLabel={status === 'connected' ? 'Manage' : 'Setup'}
onCtaClick={() => setChannelModalDef(item.channelDef!)}
badge={
isMessagingId(item.channelDef!.id) ? (
<ConnectionBadge kind="messaging" />
) : undefined
}
/>
);
}
if (item.kind === 'discovered') {
const skill = item.discoveredSkill!;
const scopeLabel = skill.legacy
? 'Legacy'
: skill.scope === 'user'
? 'User'
: skill.scope === 'project'
? 'Project'
: 'Legacy';
const scopeDot = skill.legacy
? 'bg-stone-300'
: skill.scope === 'user'
? 'bg-sage-500'
: skill.scope === 'project'
? 'bg-amber-500'
: 'bg-stone-300';
const scopeColor = skill.legacy
? 'text-stone-600'
: skill.scope === 'user'
? 'text-sage-600'
: skill.scope === 'project'
? 'text-amber-600'
: 'text-stone-600';
const canUninstall = skill.scope === 'user' && !skill.legacy;
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusDot={scopeDot}
statusLabel={scopeLabel}
statusColor={scopeColor}
ctaLabel="View"
onCtaClick={() => {
console.debug('[skills][discovered] open drawer', {
skillId: skill.id,
});
setSelectedSkill(skill);
}}
secondaryActions={
canUninstall
? [
{
label: 'Uninstall',
testId: `uninstall-skill-${skill.id}`,
icon: (
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14z"
/>
</svg>
),
onClick: () => {
console.debug('[skills][discovered] open uninstall', {
skillId: skill.id,
});
setUninstallCandidate(skill);
},
},
]
: undefined
}
/>
);
}
})}
)}
<div className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<h2 className="text-sm font-semibold text-stone-900">Integrations</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500">
Connect external apps. Connected services are sorted first and highlighted in
green.
</p>
</div>
<div className="space-y-3 px-1 pb-3">
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
<SkillCategoryFilter
categories={availableCategories}
selected={selectedCategory}
onChange={setSelectedCategory}
/>
</div>
{composioSortedEntries.length > 0 ? (
<div
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{composioSortedEntries.map(({ meta, connection }) => (
<ComposioConnectorTile
key={meta.slug}
meta={meta}
connection={connection}
hasComposioError={Boolean(composioError)}
onOpen={() => setComposioModalToolkit(meta)}
onRetryGlobal={() => void refreshComposio()}
/>
))}
</div>
) : (
<p className="px-1 py-4 text-center text-xs text-stone-400">
No integrations match your search.
</p>
)}
</div>
))
)}
{otherGroups.map(group => renderGroup(group))}
</>
}
</div>
</div>
</div>
@@ -0,0 +1,143 @@
import { fireEvent, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import '../../test/mockDefaultSkillStatusHooks';
import { renderWithProviders } from '../../test/test-utils';
import type { ChannelDefinition } from '../../types/channels';
import Skills from '../Skills';
const telegramDef: ChannelDefinition = {
id: 'telegram',
display_name: 'Telegram',
description: 'Send and receive messages on Telegram.',
icon: 'telegram',
auth_modes: [],
capabilities: [],
};
const imessageDef: ChannelDefinition = {
id: 'imessage',
display_name: 'iMessage',
description: 'Reach iMessage threads on macOS.',
icon: 'imessage',
auth_modes: [],
capabilities: [],
};
vi.mock('../../hooks/useChannelDefinitions', () => ({
useChannelDefinitions: () => ({
definitions: [telegramDef, imessageDef],
loading: false,
error: null,
}),
}));
vi.mock('../../services/api/skillsApi', async () => {
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
'../../services/api/skillsApi'
);
return {
...actual,
skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) },
};
});
vi.mock('../../lib/composio/hooks', () => ({
useComposioIntegrations: () => ({
toolkits: [],
connectionByToolkit: new Map(),
refresh: vi.fn(),
loading: false,
error: null,
}),
}));
describe('Skills page — Channels grid', () => {
it('renders configured channels as tiles in a dedicated card and opens the setup modal on click', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
const channelsHeading = screen.getByRole('heading', { name: 'Channels' });
expect(channelsHeading).toBeInTheDocument();
const channelsCard = channelsHeading.closest('.rounded-2xl');
expect(channelsCard).not.toBeNull();
const within$ = within(channelsCard as HTMLElement);
const telegramTile = within$.getByRole('button', { name: /Telegram.*Not configured.*Setup/i });
expect(telegramTile).toBeInTheDocument();
const imessageTile = within$.getByRole('button', { name: /iMessage.*Not configured.*Setup/i });
expect(imessageTile).toBeInTheDocument();
fireEvent.click(telegramTile);
const dialog = await screen.findByRole('dialog');
expect(
within(dialog).getByText(/Send and receive messages on Telegram\./i)
).toBeInTheDocument();
});
it.each([
['connected', /Connected/i, /sage/],
['connecting', /Connecting/i, /amber/],
['error', /Error/i, /coral/],
] as const)(
'styles the Telegram channel tile to reflect the %s connection state',
(status, labelPattern, classPattern) => {
const preloadedState = {
channelConnections: {
schemaVersion: 1,
migrationCompleted: true,
defaultMessagingChannel: 'telegram' as const,
connections: {
telegram: {
managed_dm: undefined,
oauth: {
channel: 'telegram' as const,
authMode: 'oauth' as const,
status,
selectedDefault: false,
lastError: null,
capabilities: [],
updatedAt: new Date().toISOString(),
},
bot_token: undefined,
api_key: undefined,
},
discord: {
managed_dm: undefined,
oauth: undefined,
bot_token: undefined,
api_key: undefined,
},
web: {
managed_dm: undefined,
oauth: undefined,
bot_token: undefined,
api_key: undefined,
},
},
},
};
renderWithProviders(<Skills />, { initialEntries: ['/skills'], preloadedState });
const channelsCard = screen
.getByRole('heading', { name: 'Channels' })
.closest('.rounded-2xl');
const telegramTile = within(channelsCard as HTMLElement).getByRole('button', {
name: new RegExp(`Telegram.*${labelPattern.source}`, 'i'),
});
expect(telegramTile.className).toMatch(classPattern);
}
);
it('does not surface a Channels chip in the category filter inside the Integrations card', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
const integrationsHeading = screen.getByRole('heading', { name: 'Integrations' });
const integrationsCard = integrationsHeading.closest('.rounded-2xl');
expect(integrationsCard).not.toBeNull();
const filterTabs = within(integrationsCard as HTMLElement)
.queryAllByRole('tab')
.map(el => el.textContent?.trim());
expect(filterTabs).not.toContain('Channels');
});
});
@@ -0,0 +1,95 @@
import { fireEvent, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { SkillSummary } from '../../services/api/skillsApi';
import '../../test/mockDefaultSkillStatusHooks';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
vi.mock('../../hooks/useChannelDefinitions', () => ({
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
}));
vi.mock('../../services/api/skillsApi', async () => {
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
'../../services/api/skillsApi'
);
const seeded = (overrides: Partial<SkillSummary>): SkillSummary => ({
id: 'skill-1',
name: 'Skill 1',
description: 'A discovered skill.',
version: '0.1.0',
author: null,
tags: [],
tools: [],
prompts: [],
location: null,
resources: [],
scope: 'user',
legacy: false,
warnings: [],
...overrides,
});
return {
...actual,
skillsApi: {
...actual.skillsApi,
listSkills: vi
.fn()
.mockResolvedValue([
seeded({ id: 'user-skill', name: 'User Skill', scope: 'user' }),
seeded({ id: 'project-skill', name: 'Project Skill', scope: 'project' }),
seeded({ id: 'legacy-skill', name: 'Legacy Skill', scope: 'user', legacy: true }),
]),
},
};
});
vi.mock('../../lib/composio/hooks', () => ({
useComposioIntegrations: () => ({
toolkits: [],
connectionByToolkit: new Map(),
refresh: vi.fn(),
loading: false,
error: null,
}),
}));
describe('Skills page — discovered skill cards', () => {
it('renders user / project / legacy scope labels and exposes Uninstall only for user-scope', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
const otherHeading = await screen.findByRole('heading', { name: 'Other' });
const otherCard = otherHeading.closest('.rounded-2xl') as HTMLElement;
const userRow = within(otherCard).getByText('User Skill').closest('.rounded-xl');
expect(userRow).not.toBeNull();
expect(within(userRow as HTMLElement).getByText('User')).toBeInTheDocument();
expect(within(userRow as HTMLElement).getByText('User').className).toMatch(/sage/);
const projectRow = within(otherCard).getByText('Project Skill').closest('.rounded-xl');
expect(within(projectRow as HTMLElement).getByText('Project').className).toMatch(/amber/);
const legacyRow = within(otherCard).getByText('Legacy Skill').closest('.rounded-xl');
expect(within(legacyRow as HTMLElement).getByText('Legacy').className).toMatch(/stone-600/);
// Uninstall surfaces for user-scope, non-legacy only.
expect(screen.queryByTestId('uninstall-skill-user-skill')).not.toBeInTheDocument();
const userMore = within(userRow as HTMLElement).getByTitle('More actions');
fireEvent.click(userMore);
expect(await screen.findByTestId('uninstall-skill-user-skill')).toBeInTheDocument();
});
it('opens the detail drawer when the View CTA is clicked', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
const otherHeading = await screen.findByRole('heading', { name: 'Other' });
const userRow = within(otherHeading.closest('.rounded-2xl') as HTMLElement)
.getByText('User Skill')
.closest('.rounded-xl') as HTMLElement;
const viewCta = within(userRow).getByRole('button', { name: 'View' });
fireEvent.click(viewCta);
expect(await screen.findByText('User Skill', { selector: 'h2' })).toBeInTheDocument();
});
});
@@ -32,13 +32,13 @@ describe('Skills page — Notion composio integration', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
expect(screen.getByRole('heading', { name: 'Integrations' })).toBeInTheDocument();
const notionTile = screen.getByRole('button', { name: /Notion.*Not connected.*Connect/i });
const notionTile = screen.getByRole('button', { name: /Notion.*Connect/i });
expect(notionTile).toBeInTheDocument();
fireEvent.click(notionTile);
expect(await screen.findByRole('heading', { name: 'Connect Notion' })).toBeInTheDocument();
expect(screen.getByText(/Connect your Notion account through Composio/i)).toBeInTheDocument();
expect(screen.getByText(/Connect your Notion account\./i)).toBeInTheDocument();
expect(screen.getByText(/OpenHuman's own agent permissions/i)).toBeInTheDocument();
});
});
+150 -3
View File
@@ -6,7 +6,60 @@
//! - `src/controllers/agentIntegrations/composio/*.ts`
//! in the backend repo for the authoritative shapes.
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
/// Accepts either a JSON string or an object whose first matching field
/// (`slug`/`id`/`name`/`key`) is a string. Lets us tolerate upstream
/// shape drift where a previously-stringy field is now nested in an
/// object — e.g. `"toolkit": {"slug": "gmail", "logo": "…"}`.
fn de_string_or_object<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
use serde::de::Error;
let v = serde_json::Value::deserialize(d)?;
match v {
serde_json::Value::String(s) => Ok(s),
serde_json::Value::Object(map) => {
for key in ["slug", "id", "name", "key"] {
if let Some(serde_json::Value::String(s)) = map.get(key) {
return Ok(s.clone());
}
}
Err(D::Error::custom(
"expected string or object with slug/id/name/key field",
))
}
other => Err(D::Error::custom(format!(
"expected string, got {}",
match other {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::Array(_) => "array",
_ => "unknown",
}
))),
}
}
/// Like [`de_string_or_object`] but optional and resilient: missing /
/// null / unrecognized object shapes return `None` instead of erroring.
fn de_opt_string_or_object<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
let v = Option::<serde_json::Value>::deserialize(d)?;
Ok(match v {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(s)) => Some(s),
Some(serde_json::Value::Object(map)) => {
let mut found = None;
for key in ["state", "value", "slug", "id", "name", "key"] {
if let Some(serde_json::Value::String(s)) = map.get(key) {
found = Some(s.clone());
break;
}
}
found
}
_ => None,
})
}
// ── Toolkits ────────────────────────────────────────────────────────
@@ -189,10 +242,13 @@ pub struct ComposioAvailableTriggersResponse {
/// One entry in `GET /agent-integrations/composio/triggers`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposioActiveTrigger {
#[serde(deserialize_with = "de_string_or_object")]
pub id: String,
#[serde(deserialize_with = "de_string_or_object")]
pub slug: String,
#[serde(deserialize_with = "de_string_or_object")]
pub toolkit: String,
#[serde(rename = "connectionId")]
#[serde(rename = "connectionId", deserialize_with = "de_string_or_object")]
pub connection_id: String,
#[serde(
rename = "triggerConfig",
@@ -200,7 +256,11 @@ pub struct ComposioActiveTrigger {
skip_serializing_if = "Option::is_none"
)]
pub trigger_config: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "de_opt_string_or_object"
)]
pub state: Option<String>,
}
@@ -429,4 +489,91 @@ mod tests {
assert_eq!(ev.metadata.uuid, "uuid-1");
assert_eq!(ev.payload["subject"], "hi");
}
#[test]
fn active_trigger_accepts_string_fields() {
let v = json!({
"id": "t1",
"slug": "GMAIL_NEW_MAIL",
"toolkit": "gmail",
"connectionId": "c1",
"state": "ACTIVE",
});
let trig: ComposioActiveTrigger = serde_json::from_value(v).unwrap();
assert_eq!(trig.id, "t1");
assert_eq!(trig.slug, "GMAIL_NEW_MAIL");
assert_eq!(trig.toolkit, "gmail");
assert_eq!(trig.connection_id, "c1");
assert_eq!(trig.state.as_deref(), Some("ACTIVE"));
}
#[test]
fn active_trigger_accepts_object_fields() {
// Mirrors upstream API drift where these fields arrive as objects
// rather than plain strings.
let v = json!({
"id": {"id": "t1"},
"slug": {"slug": "GMAIL_NEW_MAIL"},
"toolkit": {"slug": "gmail", "logo": "https://…"},
"connectionId": {"id": "c1"},
"state": {"state": "ACTIVE", "slug": "should-be-ignored"},
});
let trig: ComposioActiveTrigger = serde_json::from_value(v).unwrap();
assert_eq!(trig.id, "t1");
assert_eq!(trig.slug, "GMAIL_NEW_MAIL");
assert_eq!(trig.toolkit, "gmail");
assert_eq!(trig.connection_id, "c1");
// `state` priority must prefer the literal `state` key over metadata.
assert_eq!(trig.state.as_deref(), Some("ACTIVE"));
}
#[test]
fn active_trigger_state_falls_back_to_value() {
let v = json!({
"id": "t1",
"slug": "X",
"toolkit": "gmail",
"connectionId": "c1",
"state": {"value": "PENDING"},
});
let trig: ComposioActiveTrigger = serde_json::from_value(v).unwrap();
assert_eq!(trig.state.as_deref(), Some("PENDING"));
}
#[test]
fn active_trigger_state_missing_or_unknown_returns_none() {
let v = json!({
"id": "t1",
"slug": "X",
"toolkit": "gmail",
"connectionId": "c1",
});
let trig: ComposioActiveTrigger = serde_json::from_value(v).unwrap();
assert!(trig.state.is_none());
let v = json!({
"id": "t1",
"slug": "X",
"toolkit": "gmail",
"connectionId": "c1",
"state": {"unrelated": 42},
});
let trig: ComposioActiveTrigger = serde_json::from_value(v).unwrap();
assert!(trig.state.is_none());
}
#[test]
fn active_trigger_required_field_rejects_unsupported_object() {
// Object without any of slug/id/name/key must fail loudly so we
// notice further upstream shape drift instead of silently dropping
// the trigger.
let v = json!({
"id": {"unrelated": 42},
"slug": "X",
"toolkit": "gmail",
"connectionId": "c1",
});
let err = serde_json::from_value::<ComposioActiveTrigger>(v).unwrap_err();
assert!(err.to_string().contains("expected string or object"));
}
}