mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(skills): channels grid + integrations card polish; tolerant Composio trigger decode (#1112)
This commit is contained in:
Generated
+1
-1
@@ -4568,7 +4568,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openhuman"
|
||||
version = "0.53.10"
|
||||
version = "0.53.11"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
Generated
+2
-2
@@ -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's hosted authorization
|
||||
flow. We'll open a browser window, you approve access there, and this app will
|
||||
detect the connection automatically.
|
||||
Connect your {toolkit.name} account. We'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>
|
||||
|
||||
@@ -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}.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user