Merge pull request #1032 from escapade-mckv/fix-ux-issues

add tools info in the app card
This commit is contained in:
Bobbie 2025-07-21 16:03:44 +05:30 committed by GitHub
commit b32b035c81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 233 additions and 8 deletions

View File

@ -13,6 +13,9 @@ from .domain.exceptions import (
ConnectionNotFoundError, AppNotFoundError, MCPConnectionError,
ValidationException, PipedreamException
)
# Add HTTPX and JSON for tools listing endpoint
import httpx
import json
router = APIRouter(prefix="/pipedream", tags=["pipedream"])
pipedream_manager: Optional[PipedreamManager] = None
@ -478,6 +481,40 @@ async def get_app_icon(app_slug: str):
logger.error(f"Failed to fetch icon for app {app_slug}: {str(e)}")
raise _handle_pipedream_exception(e)
@router.get("/apps/{app_slug}/tools")
async def get_app_tools(app_slug: str):
logger.info(f"Getting tools for app: {app_slug}")
url = f"https://remote.mcp.pipedream.net/?app={app_slug}&externalUserId=tools_preview"
payload = {"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 1}
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with client.stream("POST", url, json=payload, headers=headers) as resp:
resp.raise_for_status()
tools = []
async for line in resp.aiter_lines():
if not line or not line.startswith("data:"):
continue
data_str = line[len("data:"):].strip()
try:
data_obj = json.loads(data_str)
tools = data_obj.get("result", {}).get("tools", [])
for tool in tools:
desc = tool.get("description", "") or ""
idx = desc.find("[")
if idx != -1:
tool["description"] = desc[:idx].strip()
break
except json.JSONDecodeError:
logger.warning(f"Failed to parse JSON data: {data_str}")
continue
return {"success": True, "tools": tools}
except httpx.HTTPError as e:
logger.error(f"HTTP error when fetching tools for app {app_slug}: {e}")
raise HTTPException(status_code=502, detail="Bad Gateway")
except Exception as e:
logger.error(f"Unexpected error when fetching tools for app {app_slug}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/profiles", response_model=ProfileResponse)

View File

@ -63,6 +63,7 @@
"comment-json": "^4.2.5",
"date-fns": "^3.6.0",
"diff": "^7.0.0",
"extract-colors": "^4.2.0",
"flags": "^3.2.0",
"framer-motion": "^12.6.5",
"geist": "^1.2.1",
@ -8660,6 +8661,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-colors": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/extract-colors/-/extract-colors-4.2.0.tgz",
"integrity": "sha512-+0X1EMDSea/upbny8RMEoBZdB0in6yiWFVDNZy2d/ehXINRCly+PXC8gp7enSJomeB9dhD7Sud4jzROStuHOJA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",

View File

@ -66,6 +66,7 @@
"comment-json": "^4.2.5",
"date-fns": "^3.6.0",
"diff": "^7.0.0",
"extract-colors": "^4.2.0",
"flags": "^3.2.0",
"framer-motion": "^12.6.5",
"geist": "^1.2.1",

View File

@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
@ -8,12 +8,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Plus, Settings, Zap, Bot, ChevronDown, Star, CheckCircle, Eye } from 'lucide-react';
import { Plus, Settings, Zap, Bot, ChevronDown, Star, CheckCircle, Eye, ExternalLink, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getCategoryEmoji } from '../utils';
import type { AppCardProps } from '../types';
import { usePipedreamProfiles } from '@/hooks/react-query/pipedream/use-pipedream-profiles';
import { usePipedreamAppIcon } from '@/hooks/react-query/pipedream/use-pipedream';
import { usePipedreamAppTools } from '@/hooks/react-query/pipedream/use-pipedream';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
export const AppCard: React.FC<AppCardProps> = ({
app,
@ -28,6 +32,10 @@ export const AppCard: React.FC<AppCardProps> = ({
handleCategorySelect,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: appToolsData, isLoading: isToolsLoading } = usePipedreamAppTools(app.name_slug, { enabled: isDialogOpen });
const tools = appToolsData?.tools ?? [];
const { data: profiles } = usePipedreamProfiles();
const { data: iconData } = usePipedreamAppIcon(app.name_slug, {
enabled: !app.img_src
@ -108,12 +116,159 @@ export const AppCard: React.FC<AppCardProps> = ({
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<h3 className="font-semibold text-base text-foreground truncate group-hover:text-primary transition-colors">
{app.name}
</h3>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Badge variant="outline" className="text-xs cursor-pointer hover:bg-primary/10 transition-colors flex items-center gap-1">
<Info className="h-4 w-4" />
<span className="text-xs">Info</span>
</Badge>
</DialogTrigger>
<DialogContent className="overflow-hidden max-w-4xl p-0 h-[600px]">
<DialogTitle className='sr-only'>{app.name} Tools</DialogTitle>
<div className="grid grid-cols-2 h-full">
<div className="p-8 border-r bg-gradient-to-br from-background to-muted/20">
<div className="flex flex-col h-full">
<div className="text-start mb-4">
<div className="mx-auto mb-6 relative">
<div className="h-20 w-20 rounded-3xl border bg-muted flex items-center justify-center overflow-hidden">
{(app.img_src || iconData?.icon_url) ? (
<img
src={app.img_src || iconData?.icon_url}
alt={`${app.name} icon`}
className="h-12 w-12 object-cover rounded-lg"
/>
) : (
<span className="font-bold text-2xl text-primary">
{app.name.charAt(0).toUpperCase()}
</span>
)}
</div>
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">{app.name}</h2>
<Badge variant="outline" className="mb-4 bg-muted">
{tools.length} {tools.length === 1 ? 'Tool' : 'Tools'} Available
</Badge>
</div>
<div className="mb-8 flex-1">
<p className="text-muted-foreground text-sm leading-relaxed">
{app.description}
</p>
</div>
{isConnected && (
<div className="mb-6">
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<CheckCircle className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-700 dark:text-green-300">
Connected ({connectedProfiles.length} profile{connectedProfiles.length !== 1 ? 's' : ''})
</span>
</div>
</div>
)}
<div className="space-y-3">
<Button
size="lg"
onClick={() => {
setIsDialogOpen(false);
handleConnectClick();
}}
className="w-full font-medium"
>
{mode === 'simple' ? (
<>
<Plus className="h-4 w-4" />
Connect
</>
) : mode === 'profile-only' ? (
<>
<Plus className="h-4 w-4" />
{isConnected ? 'Add Profile' : 'Connect'}
</>
) : (
<>
{isConnected ? (
<>
<Zap className="h-4 w-4" />
Add Tools
</>
) : (
<>
<Plus className="h-4 w-4" />
Connect
</>
)}
</>
)}
</Button>
</div>
</div>
</div>
<div className="flex flex-col h-full">
<div className="p-4 border-b bg-muted/30">
<h3 className="text-lg font-semibold text-foreground">Available Tools</h3>
<p className="text-sm text-muted-foreground mt-1">
{tools.length} tool{tools.length !== 1 ? 's' : ''} ready to integrate
</p>
</div>
<ScrollArea className="flex-1 max-h-[500px]">
<div className="p-4">
{isToolsLoading ? (
<div className="flex flex-col gap-2 items-center justify-center">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
) : tools.length > 0 ? (
<div className="space-y-2">
{tools.map((tool, index) => (
<Card
key={tool.name}
className="group p-4 rounded-xl transition-all duration-200"
>
<CardHeader className='p-0'>
<CardTitle>
<div className='flex items-center gap-2'>
<div className=" flex-shrink-0 h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center">
<Zap className="h-4 w-4 text-primary" />
</div>
<div className='flex flex-col items-start'>
<p className="text-sm font-medium text-foreground">
{tool.name}
</p>
<p className="text-xs text-muted-foreground line-clamp-2 font-normal">
{tool.description}
</p>
</div>
</div>
</CardTitle>
</CardHeader>
</Card>
))}
</div>
) : (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-4">
<Settings className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No tools available</p>
<p className="text-xs text-muted-foreground mt-1">Check back later for updates</p>
</div>
</div>
)}
</div>
</ScrollArea>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
{app.description}
@ -201,4 +356,4 @@ export const AppCard: React.FC<AppCardProps> = ({
</CardContent>
</Card>
);
};
};

View File

@ -28,7 +28,7 @@ const buttonVariants = cva(
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-lg px-6 has-[>svg]:px-4',
lg: 'h-10 rounded-xl px-6 has-[>svg]:px-4',
icon: 'size-9',
node_secondary: 'px-0',
},

View File

@ -11,6 +11,7 @@ import {
type PipedreamAppResponse,
type PipedreamToolsResponse,
type AppIconResponse,
type PipedreamTool,
} from './utils';
import { pipedreamKeys } from './keys';
@ -129,3 +130,15 @@ export const usePipedreamAvailableTools = createQueryHook(
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
}
);
export const usePipedreamAppTools = (appSlug: string, options?: { enabled?: boolean }) => {
return useQuery({
queryKey: ['pipedream', 'app-tools', appSlug],
queryFn: async (): Promise<{ success: boolean; tools: PipedreamTool[] }> => {
return await pipedreamApi.getAppTools(appSlug);
},
enabled: options?.enabled ?? !!appSlug,
staleTime: 5 * 60 * 1000,
retry: 2,
});
};

View File

@ -405,14 +405,26 @@ export const pipedreamApi = {
const result = await backendApi.get<AppIconResponse>(
`/pipedream/apps/${appSlug}/icon`,
{
errorContext: { operation: 'get app icon', resource: 'Pipedream app icon' },
errorContext: { operation: 'load app icon', resource: 'Pipedream app icon' },
}
);
if (!result.success) {
throw new Error(result.error?.message || 'Failed to get app icon');
}
}
return result.data!;
},
async getAppTools(appSlug: string): Promise<{ success: boolean; tools: PipedreamTool[] }> {
const result = await backendApi.get<{ success: boolean; tools: PipedreamTool[] }>(
`/pipedream/apps/${appSlug}/tools`,
{
errorContext: { operation: 'load app tools', resource: 'Pipedream app tools' },
}
);
if (!result.success) {
throw new Error(result.error?.message || 'Failed to get app tools');
}
return result.data!;
},
};