improve composio tools ux

This commit is contained in:
Ramnique Singh 2025-07-10 16:26:37 +05:30
parent 2ee4f37464
commit 3063c9fea9
3 changed files with 268 additions and 273 deletions

View file

@ -158,6 +158,16 @@ export function Composio() {
toolkit.meta.description.toLowerCase().includes(searchLower) ||
toolkit.slug.toLowerCase().includes(searchLower)
);
}).sort((a, b) => {
// Sort by actual connection status first (only connected tools, not no-auth)
const aConnected = !a.no_auth && projectConfig?.composioConnectedAccounts?.[a.slug]?.status === 'ACTIVE';
const bConnected = !b.no_auth && projectConfig?.composioConnectedAccounts?.[b.slug]?.status === 'ACTIVE';
if (aConnected && !bConnected) return -1;
if (!aConnected && bConnected) return 1;
// If both have same connection status, maintain original order (don't sort alphabetically)
return 0;
});
if (loading) {
@ -191,14 +201,6 @@ export function Composio() {
return (
<div className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<div className="shrink-0">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 flex items-center gap-4">
@ -276,6 +278,8 @@ export function Composio() {
onClose={handleCloseToolsPanel}
projectConfig={projectConfig}
onUpdateToolsSelection={handleUpdateToolsSelection}
onProjectConfigUpdate={handleProjectConfigUpdate}
onRemoveToolkitTools={handleRemoveToolkitTools}
isSaving={savingTools}
/>
</div>

View file

@ -2,15 +2,15 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { PictureImg } from '@/components/ui/picture-img';
import { Checkbox } from '@heroui/react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { listTools } from '@/app/actions/composio_actions';
import { Button, Checkbox } from '@heroui/react';
import { ChevronLeft, ChevronRight, LinkIcon, Loader2, UnlinkIcon } from 'lucide-react';
import { listTools, deleteConnectedAccount } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZTool, ZListResponse } from '@/app/lib/composio/composio';
import { SlidePanel } from '@/components/ui/slide-panel';
import { Project } from '@/app/lib/types/project_types';
import { ToolkitAuthModal } from './ToolkitAuthModal';
type ToolType = z.infer<typeof ZTool>;
type ToolListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>;
@ -29,6 +29,8 @@ interface ComposioToolsPanelProps {
onClose: () => void;
projectConfig: ProjectType | null;
onUpdateToolsSelection: (selectedToolObjects: ToolType[]) => void;
onProjectConfigUpdate: () => void;
onRemoveToolkitTools: (toolkitSlug: string) => void;
isSaving: boolean;
}
@ -38,6 +40,8 @@ export function ComposioToolsPanel({
onClose,
projectConfig,
onUpdateToolsSelection,
onProjectConfigUpdate,
onRemoveToolkitTools,
isSaving
}: ComposioToolsPanelProps) {
const params = useParams();
@ -51,6 +55,8 @@ export function ComposioToolsPanel({
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [hasChanges, setHasChanges] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isProcessingAuth, setIsProcessingAuth] = useState(false);
const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => {
try {
@ -117,6 +123,34 @@ export function ComposioToolsPanel({
setHasChanges(false);
}, [onUpdateToolsSelection, selectedTools, tools]);
const handleConnect = useCallback(() => {
setShowAuthModal(true);
}, []);
const handleDisconnect = useCallback(async () => {
if (!toolkit) return;
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
setIsProcessingAuth(true);
try {
if (connectedAccountId) {
await deleteConnectedAccount(projectId, toolkit.slug, connectedAccountId);
onProjectConfigUpdate();
onRemoveToolkitTools(toolkit.slug);
}
} catch (err: any) {
console.error('Disconnect failed:', err);
} finally {
setIsProcessingAuth(false);
}
}, [projectId, toolkit, projectConfig, onProjectConfigUpdate, onRemoveToolkitTools]);
const handleAuthComplete = useCallback(() => {
setShowAuthModal(false);
onProjectConfigUpdate();
}, [onProjectConfigUpdate]);
const handleClose = useCallback(() => {
setTools([]);
setSelectedTools(new Set());
@ -151,119 +185,170 @@ export function ComposioToolsPanel({
const isToolkitConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
return (
<SlidePanel
isOpen={isOpen}
onClose={handleClose}
title={
<div className="flex items-center gap-3">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
width={24}
height={24}
className="rounded-md object-cover"
/>
)}
<span>{toolkit.name}</span>
</div>
}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Available Tools</h4>
<div className="flex items-center gap-2">
{!isToolkitConnected && !toolkit.no_auth && (
<div className="text-sm text-orange-600 dark:text-orange-400 px-3 py-1 rounded-full bg-orange-50 dark:bg-orange-900/20">
Toolkit not connected
</div>
)}
{hasChanges && (
<Button
variant="primary"
size="sm"
onClick={handleSaveTools}
disabled={isSaving || !isToolkitConnected}
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-b-transparent border-white mr-2" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
)}
</div>
<>
<SlidePanel
isOpen={isOpen}
onClose={handleClose}
title={
<div className="flex items-center gap-3">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
width={24}
height={24}
className="rounded-md object-cover"
/>
)}
<span>{toolkit.name}</span>
</div>
</div>
{/* Scrollable Tools List */}
<div className="flex-1 overflow-y-auto">
{toolsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p>
</div>
) : (
<div className="space-y-4">
{tools.map((tool) => (
<div key={tool.slug} className={`group p-4 rounded-lg transition-all duration-200 border border-transparent ${
isToolkitConnected
? 'bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:border-gray-200 dark:hover:border-gray-600'
: 'bg-gray-100/50 dark:bg-gray-900/50 opacity-60'
}`}>
<div className="flex items-start gap-3">
<Checkbox
isSelected={selectedTools.has(tool.slug)}
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
size="sm"
isDisabled={!isToolkitConnected}
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{tool.name}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{tool.description}
</p>
</div>
}
>
<div className="flex flex-col h-full">
{/* Connection Status Banner */}
{!toolkit.no_auth && (
<div className={`mb-6 p-4 rounded-lg border-2 ${
isToolkitConnected
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
: 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
isToolkitConnected ? 'bg-emerald-500' : 'bg-orange-500'
}`}></div>
<div>
<h3 className={`font-semibold text-sm ${
isToolkitConnected
? 'text-emerald-800 dark:text-emerald-200'
: 'text-orange-800 dark:text-orange-200'
}`}>
{isToolkitConnected ? 'Toolkit Connected' : 'Authentication Required'}
</h3>
<p className={`text-xs mt-0.5 ${
isToolkitConnected
? 'text-emerald-700 dark:text-emerald-300'
: 'text-orange-700 dark:text-orange-300'
}`}>
{isToolkitConnected
? 'You can select and use tools from this toolkit'
: 'Connect your account to access and use tools'
}
</p>
</div>
</div>
))}
<Button
variant="solid"
size="sm"
onPress={isToolkitConnected ? handleDisconnect : handleConnect}
disabled={isProcessingAuth}
color={isToolkitConnected ? "danger" : "primary"}
isLoading={isProcessingAuth}
startContent={isToolkitConnected ? <UnlinkIcon className="h-4 w-4" /> : <LinkIcon className="h-4 w-4" />}
>
{isToolkitConnected ? 'Disconnect' : 'Connect Now'}
</Button>
</div>
</div>
)}
</div>
{/* Fixed Pagination Controls */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex items-center justify-end">
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={handlePreviousPage}
disabled={cursorHistory.length === 0 || toolsLoading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleNextPage}
disabled={!nextCursor || toolsLoading}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Available Tools</h4>
<div className="flex items-center gap-2">
{hasChanges && (
<Button
variant="solid"
size="sm"
color="primary"
onPress={handleSaveTools}
disabled={isSaving || !isToolkitConnected}
isLoading={isSaving}
>
Save Changes
</Button>
)}
</div>
</div>
</div>
{/* Scrollable Tools List */}
<div className="flex-1 overflow-y-auto">
{toolsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p>
</div>
) : (
<div className="space-y-4">
{tools.map((tool) => (
<div key={tool.slug} className={`group p-4 rounded-lg transition-all duration-200 border border-transparent ${
isToolkitConnected
? 'bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:border-gray-200 dark:hover:border-gray-600'
: 'bg-gray-100/50 dark:bg-gray-900/50 opacity-60'
}`}>
<div className="flex items-start gap-3">
<Checkbox
isSelected={selectedTools.has(tool.slug)}
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
size="sm"
isDisabled={!isToolkitConnected}
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{tool.name}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{tool.description}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Fixed Pagination Controls */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex items-center justify-end">
<div className="flex items-center gap-2">
<Button
variant="bordered"
size="sm"
onClick={handlePreviousPage}
disabled={cursorHistory.length === 0 || toolsLoading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant="bordered"
size="sm"
onClick={handleNextPage}
disabled={!nextCursor || toolsLoading}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
</div>
</div>
</SlidePanel>
</SlidePanel>
{/* Auth Modal */}
{toolkit && (
<ToolkitAuthModal
key={toolkit.slug}
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={toolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -1,29 +1,28 @@
'use client';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useCallback } from 'react';
import { PictureImg } from '@/components/ui/picture-img';
import { Wrench } from 'lucide-react';
import clsx from 'clsx';
import { Spinner } from '@heroui/react';
import { deleteConnectedAccount } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZToolkit } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { ToolkitAuthModal } from './ToolkitAuthModal';
import { Chip } from '@heroui/react';
import { LinkIcon } from 'lucide-react';
type ToolkitType = z.infer<typeof ZToolkit>;
type ProjectType = z.infer<typeof Project>;
const toolkitCardStyles = {
base: clsx(
"group p-6 rounded-xl transition-all duration-200",
"bg-white dark:bg-gray-900 shadow-sm dark:shadow-none",
"border-2 border-gray-200/80 dark:border-gray-700/80",
"hover:shadow-md dark:hover:shadow-none",
"hover:border-blue-200 dark:hover:border-blue-900",
"min-h-[280px] flex flex-col"
"group p-6 rounded-xl transition-all duration-200 cursor-pointer",
"bg-white dark:bg-gray-900",
"border border-gray-200 dark:border-gray-700",
"shadow-md dark:shadow-gray-900/20",
"hover:shadow-lg dark:hover:shadow-gray-900/30",
"hover:border-blue-300 dark:hover:border-blue-600",
"hover:bg-gray-50/50 dark:hover:bg-gray-800/50",
"hover:-translate-y-1",
"min-h-[200px] flex flex-col"
),
};
@ -48,45 +47,9 @@ export function ToolkitCard({
onProjectConfigUpdate,
onRemoveToolkitTools
}: ToolkitCardProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const handleToggleConnection = useCallback(async () => {
const newState = !isConnected;
// Clear any previous error when starting a new operation
setError(null);
if (newState) {
// Show authentication modal
setShowAuthModal(true);
} else {
// Disconnect - remove the connected account
setIsProcessing(true);
try {
if (connectedAccountId) {
await deleteConnectedAccount(projectId, toolkit.slug, connectedAccountId);
onProjectConfigUpdate();
onRemoveToolkitTools(toolkit.slug);
} else {
// Fallback: just refresh the project config
onProjectConfigUpdate();
}
} catch (err: any) {
console.error('Disconnect failed:', err);
const errorMessage = err.message || 'Failed to disconnect toolkit';
setError(errorMessage);
} finally {
setIsProcessing(false);
}
}
}, [projectId, toolkit.slug, isConnected, connectedAccountId, onProjectConfigUpdate, onRemoveToolkitTools]);
const handleAuthComplete = useCallback(() => {
// Update project config when authentication completes
onProjectConfigUpdate();
}, [onProjectConfigUpdate]);
const handleCardClick = useCallback(() => {
onManageTools();
}, [onManageTools]);
// Calculate selected tools count for this toolkit
const selectedToolsCount = projectConfig?.composioSelectedTools?.filter(tool =>
@ -94,127 +57,70 @@ export function ToolkitCard({
).length || 0;
return (
<div className={toolkitCardStyles.base}>
<div className={toolkitCardStyles.base} onClick={handleCardClick}>
<div className="flex flex-col h-full">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
className="w-8 h-8 rounded-md object-cover"
/>
)}
<div>
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100">
{toolkit.name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
{toolkit.meta.tools_count} tools
</span>
{selectedToolsCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300">
{selectedToolsCount} selected
</span>
)}
{toolkit.no_auth && (
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300">
No Auth
</span>
)}
</div>
{/* Header */}
<div className="flex items-start gap-3 mb-4">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
className="w-8 h-8 rounded-md object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100 truncate">
{toolkit.name}
</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Chip
color="secondary"
variant="faded"
size="sm"
>
{selectedToolsCount > 0
? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected`
: `${toolkit.meta.tools_count} tools`
}
</Chip>
</div>
</div>
<div className="flex items-center gap-2">
{toolkit.no_auth ? (
<div className="flex items-center gap-2">
<Switch
checked={true}
onCheckedChange={() => {}} // No-op for no-auth toolkits
disabled={true}
className={clsx(
"data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600",
"data-[state=unchecked]:bg-emerald-500 dark:data-[state=unchecked]:bg-emerald-600",
"opacity-50 cursor-not-allowed",
"scale-75"
)}
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Always Available
</span>
</div>
) : (
<Switch
checked={isConnected}
onCheckedChange={handleToggleConnection}
disabled={isProcessing}
className={clsx(
"data-[state=checked]:bg-blue-500 dark:data-[state=checked]:bg-blue-600",
"data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-700",
isProcessing && "opacity-50 cursor-not-allowed",
"scale-75"
)}
/>
)}
</div>
</div>
{/* Description */}
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-3">
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-3">
{toolkit.meta.description}
</p>
</div>
<div className="mt-auto">
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="text-xs text-gray-400 dark:text-gray-500">
ID: {toolkit.slug}
</div>
<div className="flex items-center gap-2">
{isProcessing && (
<div className="flex items-center gap-1 text-xs py-1 px-2 rounded-full text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20">
<Spinner size="sm" />
<span>Processing...</span>
</div>
{isConnected && !toolkit.no_auth && (
<Chip
color='success'
variant='flat'
size="sm"
startContent={<LinkIcon className="w-3 h-3 mr-1" />}
>
Connected
</Chip>
)}
{(isConnected || toolkit.no_auth) && !isProcessing && (
<div className="text-xs py-1 px-2 rounded-full text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20">
{toolkit.no_auth ? 'Available' : 'Connected'}
</div>
{toolkit.no_auth && (
<Chip
color='success'
variant='flat'
size="sm"
>
Ready
</Chip>
)}
{error && (
<div className="text-xs py-1 px-2 rounded-full text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">
Error: {error}
</div>
)}
<Button
size="sm"
variant="secondary"
onClick={onManageTools}
className="text-xs"
>
<div className="inline-flex items-center">
<Wrench className="h-3.5 w-3.5" />
<span className="ml-1.5">Tools</span>
</div>
</Button>
</div>
</div>
</div>
</div>
<ToolkitAuthModal
key={toolkit.slug}
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={toolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
</div>
);
}