From db3e1ba8da9e3d3814f6540165e0eb858906a4d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 13:33:48 -0500 Subject: [PATCH 1/4] [dev] [Marfuen] mariano/better-integrations (#1744) * feat(integrations): add AI-powered relevant tasks dialog - Add server action to generate relevant tasks for integrations using AI - Display relevant tasks in dialog with reasons and auto-generated prompts - Add navigation to task automation page with pre-filled prompt - Include taskTemplateId in tasks API response - Add loading states and skeleton loaders for better UX * feat(ui): integrate AI SDK prompt input components - Add AI SDK prompt input components from @ai-elements/prompt-input - Create input-group component for flexible input wrapper - Replace custom textarea with PromptInput in chat and empty state - Add Cmd+Enter/Ctrl+Enter keyboard shortcut for submission - Display keyboard shortcut hint on submit button - Auto-expand textarea up to 400px height * fix(ui): update shadcn component imports to use relative paths - Fix imports in all shadcn components to use relative paths - Ensure components work correctly in monorepo structure - Update button, command, dialog, dropdown-menu, hover-card, input, select, and textarea components * chore: update dependencies lock file * fix(ui): export ButtonProps and add type annotations to drawer components - Export ButtonProps type from button component for use in submit-button - Add explicit type annotations to drawer components to fix TypeScript build errors * revert: restore original shadcn component styles Revert unnecessary changes to shadcn components. Only keep ButtonProps export needed for submit-button. * chore(ui): add export paths for new prompt-input and input-group components --------- Co-authored-by: Mariano Fuentes --- apps/api/src/tasks/tasks.service.ts | 1 + apps/app/package.json | 1 + .../actions/get-relevant-tasks.ts | 70 + .../components/IntegrationsGrid.tsx | 298 +++- .../app/(app)/[orgId]/integrations/page.tsx | 17 +- .../automation/[automationId]/chat.tsx | 222 +-- .../components/chat/EmptyState.tsx | 62 +- bun.lock | 93 +- packages/ui/components.json | 24 + packages/ui/package.json | 24 +- .../components/ai-elements/prompt-input.tsx | 1225 +++++++++++++++++ packages/ui/src/components/button.tsx | 4 +- packages/ui/src/components/drawer.tsx | 14 +- packages/ui/src/components/index.ts | 4 + packages/ui/src/components/input-group.tsx | 159 +++ 15 files changed, 1987 insertions(+), 231 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/actions/get-relevant-tasks.ts create mode 100644 packages/ui/components.json create mode 100644 packages/ui/src/components/ai-elements/prompt-input.tsx create mode 100644 packages/ui/src/components/input-group.tsx diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 375c64e74..12c6d06a5 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -29,6 +29,7 @@ export class TasksService { status: task.status, createdAt: task.createdAt, updatedAt: task.updatedAt, + taskTemplateId: task.taskTemplateId, })); } catch (error) { console.error('Error fetching tasks:', error); diff --git a/apps/app/package.json b/apps/app/package.json index 4fa4694a8..e2476b603 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -62,6 +62,7 @@ "@vercel/sandbox": "^0.0.21", "@vercel/sdk": "^1.7.1", "ai": "^5.0.60", + "ai-elements": "^1.6.1", "axios": "^1.9.0", "better-auth": "^1.3.27", "botid": "^1.5.5", diff --git a/apps/app/src/app/(app)/[orgId]/integrations/actions/get-relevant-tasks.ts b/apps/app/src/app/(app)/[orgId]/integrations/actions/get-relevant-tasks.ts new file mode 100644 index 000000000..14b67aee1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/actions/get-relevant-tasks.ts @@ -0,0 +1,70 @@ +'use server'; + +import { openai } from '@ai-sdk/openai'; +import { generateObject } from 'ai'; +import { z } from 'zod'; + +const RelevantTasksSchema = z.object({ + relevantTasks: z.array( + z.object({ + taskTemplateId: z.string(), + taskName: z.string(), + reason: z.string(), + prompt: z.string(), + }), + ), +}); + +export async function getRelevantTasksForIntegration( + integrationName: string, + integrationDescription: string, + taskTemplates: Array<{ id: string; name: string; description: string }>, +): Promise<{ taskTemplateId: string; taskName: string; reason: string; prompt: string }[]> { + if (taskTemplates.length === 0) { + return []; + } + + // Format task templates for the prompt (truncate descriptions to reduce token usage) + const tasksList = taskTemplates + .map((task) => { + // Truncate description to max 100 chars to reduce token usage + const truncatedDesc = + task.description.length > 100 + ? task.description.substring(0, 100) + '...' + : task.description; + return `${task.id}|${task.name}|${truncatedDesc}`; + }) + .join('\n'); + + const systemPrompt = `GRC expert. Find tasks relevant to integration. Return JSON with: taskTemplateId, taskName, reason (1 sentence), prompt (actionable, ready to use). Be selective.`; + + const userPrompt = `Integration: ${integrationName} - ${integrationDescription} + +Tasks (format: ID|Name|Description): +${tasksList} + +Return only clearly relevant tasks.`; + + const promptSize = (systemPrompt + userPrompt).length; + console.log(`[getRelevantTasks] Prompt size: ${promptSize} chars, ${taskTemplates.length} tasks`); + + try { + const startTime = Date.now(); + const { object, usage } = await generateObject({ + model: openai('gpt-4.1-mini'), + schema: RelevantTasksSchema, + system: systemPrompt, + prompt: userPrompt, + }); + const duration = Date.now() - startTime; + + console.log( + `[getRelevantTasks] Generated ${object.relevantTasks.length} tasks in ${duration}ms (tokens: ${usage?.totalTokens || 'unknown'})`, + ); + + return object.relevantTasks; + } catch (error) { + console.error('Error generating relevant tasks:', error); + return []; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx index c73fae35a..ff73b1f42 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx @@ -1,5 +1,6 @@ 'use client'; +import { api } from '@/lib/api-client'; import { Badge } from '@comp/ui/badge'; import { Button } from '@comp/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; @@ -10,12 +11,14 @@ import { DialogHeader, DialogTitle, } from '@comp/ui/dialog'; -import { ArrowRight, Sparkles } from 'lucide-react'; +import { Skeleton } from '@comp/ui/skeleton'; +import { ArrowRight, CheckCircle2, Loader2, Plug, Sparkles, Zap } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { useMemo, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { getRelevantTasksForIntegration } from '../actions/get-relevant-tasks'; import { CATEGORIES, INTEGRATIONS, @@ -26,11 +29,104 @@ import { SearchInput } from './SearchInput'; const LOGO_TOKEN = 'pk_AZatYxV5QDSfWpRDaBxzRQ'; -export function IntegrationsGrid() { +interface RelevantTask { + taskTemplateId: string; + taskName: string; + reason: string; + prompt: string; +} + +function TaskCard({ task, orgId }: { task: RelevantTask; orgId: string }) { + const [isNavigating, setIsNavigating] = useState(false); + const router = useRouter(); + + const handleCardClick = async () => { + setIsNavigating(true); + toast.loading('Opening task automation...', { id: 'navigating' }); + + try { + // Fetch all tasks and find one with matching template ID + const response = await api.get>( + '/v1/tasks', + orgId, + ); + + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch tasks'); + } + + const matchingTask = response.data.find((t) => t.taskTemplateId === task.taskTemplateId); + + if (!matchingTask) { + toast.dismiss('navigating'); + toast.error(`Task "${task.taskName}" not found. Please create it first.`); + setIsNavigating(false); + await router.push(`/${orgId}/tasks`); + return; + } + + const url = `/${orgId}/tasks/${matchingTask.id}/automation/new?prompt=${encodeURIComponent(task.prompt)}`; + toast.dismiss('navigating'); + toast.success('Redirecting...', { duration: 1000 }); + + // Use window.location for immediate navigation + window.location.href = url; + } catch (error) { + console.error('Error finding task:', error); + toast.dismiss('navigating'); + toast.error('Failed to find task'); + setIsNavigating(false); + } + }; + + return ( +
+ {isNavigating && ( +
+ +
+

Opening task...

+

+ Redirecting to automation with prompt pre-filled +

+
+
+ )} +
+
+
+
+
+
+

+ {task.taskName} +

+
+

+ {task.reason} +

+
+ +
+
+
+ ); +} + +export function IntegrationsGrid({ + taskTemplates, +}: { + taskTemplates: Array<{ id: string; name: string; description: string }>; +}) { const { orgId } = useParams<{ orgId: string }>(); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('All'); const [selectedIntegration, setSelectedIntegration] = useState(null); + const [relevantTasks, setRelevantTasks] = useState([]); + const [isLoadingTasks, setIsLoadingTasks] = useState(false); // Filter integrations with fuzzy search const filteredIntegrations = useMemo(() => { @@ -62,6 +158,29 @@ export function IntegrationsGrid() { toast.success('Prompt copied to clipboard!'); }; + useEffect(() => { + if (selectedIntegration && orgId && taskTemplates.length > 0) { + setIsLoadingTasks(true); + getRelevantTasksForIntegration( + selectedIntegration.name, + selectedIntegration.description, + taskTemplates, + ) + .then((tasks) => { + setRelevantTasks(tasks); + }) + .catch((error) => { + console.error('Error fetching relevant tasks:', error); + setRelevantTasks([]); + }) + .finally(() => { + setIsLoadingTasks(false); + }); + } else { + setRelevantTasks([]); + } + }, [selectedIntegration, orgId, taskTemplates]); + return (
{/* Search and Filters */} @@ -232,74 +351,121 @@ export function IntegrationsGrid() { {/* Integration Detail Modal */} {selectedIntegration && ( setSelectedIntegration(null)}> - - -
-
- {`${selectedIntegration.name} -
-
- {selectedIntegration.name} -

- {selectedIntegration.category} -

-
+ +
+ {/* Header with gradient background */} +
+ +
+
+ {`${selectedIntegration.name} +
+
+
+ + {selectedIntegration.name} + + {selectedIntegration.popular && ( + + Popular + + )} +
+
+ + {selectedIntegration.category} + + + Integration +
+
+
+ + {selectedIntegration.description} + +
- - {selectedIntegration.description} - - -
- {/* Setup Instructions */} -
-

How to Connect

-
-

- Use the example prompts below, or describe what you need in your own words. The - agent will handle authentication and setup. -

- {selectedIntegration.setupHint && ( -

- Typically requires:{' '} - {selectedIntegration.setupHint} +

+ {/* Setup Instructions */} +
+
+
+ +
+

How to Connect

+
+
+

+ Click on any relevant task below to create an automation with{' '} + {selectedIntegration.name}. The automation will be pre-configured with a + prompt tailored to that task. The agent will ask you for the necessary + permissions and API keys if required.

- )} + {selectedIntegration.setupHint && ( +
+ +

+ Typically requires:{' '} + {selectedIntegration.setupHint} +

+
+ )} +
-
- {/* Example Prompts */} -
-

Example Prompts

-
- {selectedIntegration.examplePrompts.map((prompt, index) => ( - - ))} -
-
- Click any prompt to copy - - Go to Tasks - - +
+ )}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx index 36b219929..6f5aae5e6 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx @@ -1,6 +1,19 @@ +import { db } from '@db'; import { IntegrationsGrid } from './components/IntegrationsGrid'; -export default function IntegrationsPage() { +export default async function IntegrationsPage() { + // Fetch task templates server-side + const taskTemplates = await db.frameworkEditorTaskTemplate.findMany({ + select: { + id: true, + name: true, + description: true, + }, + orderBy: { + name: 'asc', + }, + }); + return (
@@ -17,7 +30,7 @@ export default function IntegrationsPage() {
{/* Integrations Grid */} - +
); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx index 060830ffd..22c8990af 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx @@ -2,8 +2,19 @@ import { cn } from '@/lib/utils'; import { useChat } from '@ai-sdk/react'; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputProvider, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputController, +} from '@comp/ui'; import Image from 'next/image'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useCallback, useEffect } from 'react'; import { Conversation, ConversationContent, @@ -14,7 +25,6 @@ import { EmptyState } from './components/chat/EmptyState'; import { Message } from './components/chat/message'; import type { ChatUIMessage } from './components/chat/types'; import { PanelHeader } from './components/panels/panels'; -import { Textarea } from './components/ui/textarea'; import { useChatHandlers } from './hooks/use-chat-handlers'; import { useTaskAutomation } from './hooks/use-task-automation'; import { useSharedChatContext } from './lib/chat-context'; @@ -31,6 +41,109 @@ interface Props { isLoadingSuggestions?: boolean; } +function ChatInput({ + validateAndSubmitMessage, + status, +}: { + validateAndSubmitMessage: (text: string) => void; + status: string; +}) { + const { textInput } = usePromptInputController(); + + return ( +
+ { + validateAndSubmitMessage(text); + }} + > + + + + + + + + +
+ ); +} + +function ChatContent({ + hasMessages, + scriptUrl, + messages, + orgId, + handleSecretAdded, + handleInfoProvided, + validateAndSubmitMessage, + status, + suggestions, + isLoadingSuggestions, +}: { + hasMessages: boolean; + scriptUrl?: string; + messages: ChatUIMessage[]; + orgId: string; + handleSecretAdded: (secretName: string) => void; + handleInfoProvided: (info: Record) => void; + validateAndSubmitMessage: (text: string) => void; + status: string; + suggestions?: { title: string; prompt: string; vendorName?: string; vendorWebsite?: string }[]; + isLoadingSuggestions?: boolean; +}) { + const { textInput } = usePromptInputController(); + + const handleExampleClick = useCallback( + (prompt: string) => { + textInput.setInput(prompt); + }, + [textInput], + ); + + if (!hasMessages) { + return ( +
+ validateAndSubmitMessage(textInput.value)} + suggestions={suggestions} + isLoadingSuggestions={isLoadingSuggestions} + /> +
+ ); + } + + return ( +
+ + + {messages.map((message) => ( + + ))} + + + + + +
+ ); +} + export function Chat({ className, orgId, @@ -40,27 +153,15 @@ export function Chat({ suggestions, isLoadingSuggestions = false, }: Props) { - const [input, setInput] = useState(''); + const searchParams = useSearchParams(); + const initialPrompt = searchParams.get('prompt') || ''; const { chat, updateAutomationId, automationIdRef } = useSharedChatContext(); const { messages, sendMessage, status } = useChat({ chat, }); const { setChatStatus, scriptUrl } = useTaskAutomationStore(); - const inputRef = useRef(null); const { automation } = useTaskAutomation(); - // Auto-resize textarea - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - setInput(e.target.value); - // Reset height to auto to get the correct scrollHeight - e.target.style.height = 'auto'; - // Set height to scrollHeight (content height) - e.target.style.height = `${e.target.scrollHeight}px`; - }, - [setInput], - ); - // Update shared ref when automation is loaded from hook if (automation?.id && automationIdRef.current === 'new') { automationIdRef.current = automation.id; @@ -72,7 +173,7 @@ export function Chat({ const { validateAndSubmitMessage, handleSecretAdded, handleInfoProvided } = useChatHandlers({ sendMessage, - setInput, + setInput: () => {}, // Not needed with PromptInputProvider orgId, taskId, automationId: automationIdRef.current, @@ -80,14 +181,6 @@ export function Chat({ updateAutomationId, }); - const handleExampleClick = useCallback( - (prompt: string) => { - setInput(prompt); - inputRef.current?.focus(); - }, - [setInput], - ); - useEffect(() => { setChatStatus(status); }, [status, setChatStatus]); @@ -121,71 +214,20 @@ export function Chat({ {/* Messages Area */} - {!hasMessages ? ( -
{ - event.preventDefault(); - validateAndSubmitMessage(input); - }} - > - validateAndSubmitMessage(input)} - suggestions={suggestions} - isLoadingSuggestions={isLoadingSuggestions} - /> - - ) : ( -
- - - {messages.map((message) => ( - - ))} - - - - -
{ - event.preventDefault(); - validateAndSubmitMessage(input); - }} - > -