Let copilot add external triggers

feat: Enhance external trigger handling in Copilot

- Added support for flexible schemas in external triggers, allowing configuration changes without stripping any data.
- Introduced a new `onRequestTriggerSetup` callback in the Action component to facilitate trigger setup requests.
- Implemented a modal for trigger configuration, improving user experience when setting up external triggers.
- Updated the ComposioTriggerTypesPanel to auto-select trigger types based on initial configuration.

This update significantly improves the management and setup of external triggers within the Copilot interface.
This commit is contained in:
tusharmagar 2025-10-06 15:56:21 +08:00
parent 885a7f3753
commit 69eec4e41b
6 changed files with 402 additions and 19 deletions

View file

@ -93,6 +93,10 @@ export function validateConfigChanges(configType: string, configChanges: Record<
}).passthrough();
break;
}
case 'external_trigger': {
// External triggers have flexible schemas per provider; do not strip any config.
return { changes: configChanges };
}
default:
return { error: `Unknown config type: ${configType}` };
}

View file

@ -0,0 +1,237 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Modal, ModalBody, ModalContent, ModalHeader } from '@heroui/react';
import { z } from 'zod';
import { ZToolkit } from '@/src/application/lib/composio/types';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { Project } from '@/src/entities/models/project';
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
import { ComposioTriggerTypesPanel } from '../../workflow/components/ComposioTriggerTypesPanel';
import { TriggerConfigForm } from '../../workflow/components/TriggerConfigForm';
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
import { fetchProject } from '@/app/actions/project.actions';
import { createComposioTriggerDeployment } from '@/app/actions/composio.actions';
import { Button, Spinner } from '@heroui/react';
interface TriggerSetupModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
initialToolkitSlug?: string | null;
initialTriggerTypeSlug?: string | null;
initialTriggerConfig?: Record<string, unknown> | null;
onCreated?: () => void;
}
type Toolkit = z.infer<typeof ZToolkit>;
type TriggerType = z.infer<typeof ComposioTriggerType>;
type ProjectConfig = z.infer<typeof Project>;
export function TriggerSetupModal({
isOpen,
onClose,
projectId,
initialToolkitSlug = null,
initialTriggerTypeSlug = null,
initialTriggerConfig = null,
onCreated,
}: TriggerSetupModalProps) {
const [selectedToolkit, setSelectedToolkit] = useState<Toolkit | null>(null);
const [selectedTriggerType, setSelectedTriggerType] = useState<TriggerType | null>(null);
const [projectConfig, setProjectConfig] = useState<ProjectConfig | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingTriggerTypeSlug, setPendingTriggerTypeSlug] = useState<string | null>(null);
const [initialConfig, setInitialConfig] = useState<Record<string, unknown> | undefined>();
const loadProjectConfig = useCallback(async () => {
try {
const config = await fetchProject(projectId);
setProjectConfig(config);
} catch (err) {
console.error('Failed to fetch project configuration', err);
}
}, [projectId]);
const resetState = useCallback(() => {
setSelectedToolkit(null);
setSelectedTriggerType(null);
setShowAuthModal(false);
setError(null);
setPendingTriggerTypeSlug(initialTriggerTypeSlug);
setInitialConfig(initialTriggerConfig ?? undefined);
}, [initialTriggerConfig, initialTriggerTypeSlug]);
useEffect(() => {
if (!isOpen) {
return;
}
resetState();
void loadProjectConfig();
}, [isOpen, loadProjectConfig, resetState]);
const requiresAuth = useMemo(() => {
if (!selectedToolkit) return false;
return !selectedToolkit.no_auth;
}, [selectedToolkit]);
const hasActiveConnection = useMemo(() => {
if (!selectedToolkit) return false;
const status = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status;
return status === 'ACTIVE';
}, [projectConfig, selectedToolkit]);
const handleSelectToolkit = useCallback((toolkit: Toolkit) => {
setSelectedToolkit(toolkit);
setSelectedTriggerType(null);
setError(null);
if (!initialToolkitSlug || toolkit.slug === initialToolkitSlug) {
setPendingTriggerTypeSlug(initialTriggerTypeSlug);
} else {
setPendingTriggerTypeSlug(null);
}
}, [initialToolkitSlug, initialTriggerTypeSlug]);
const handleSelectTriggerType = useCallback((triggerType: TriggerType) => {
setSelectedTriggerType(triggerType);
setError(null);
setPendingTriggerTypeSlug(null);
if (requiresAuth && !hasActiveConnection) {
setShowAuthModal(true);
}
}, [requiresAuth, hasActiveConnection]);
const handleAuthComplete = useCallback(async () => {
await loadProjectConfig();
setShowAuthModal(false);
}, [loadProjectConfig]);
const handleSubmit = useCallback(async (triggerConfig: Record<string, unknown>) => {
if (!selectedToolkit || !selectedTriggerType) {
return;
}
try {
setIsSubmitting(true);
setError(null);
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
if (!connectedAccountId) {
setShowAuthModal(true);
throw new Error('Connect this toolkit before creating a trigger.');
}
await createComposioTriggerDeployment({
projectId,
triggerTypeSlug: selectedTriggerType.slug,
connectedAccountId,
triggerConfig,
});
onCreated?.();
onClose();
} catch (err: any) {
console.error('Failed to create trigger', err);
setError(err?.message || 'Failed to create trigger. Please try again.');
} finally {
setIsSubmitting(false);
}
}, [onClose, onCreated, projectConfig, projectId, selectedToolkit, selectedTriggerType]);
const handleClose = useCallback(() => {
if (isSubmitting) {
return;
}
onClose();
}, [isSubmitting, onClose]);
return (
<>
<Modal
isOpen={isOpen}
onClose={handleClose}
size="5xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[90vh]'
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Set up External Trigger</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Follow the guided flow to authenticate and configure the trigger.
</p>
</ModalHeader>
<ModalBody className="pb-6">
{!selectedToolkit && (
<SelectComposioToolkit
key={isOpen ? 'toolkit-selector' : 'toolkit-selector-hidden'}
projectId={projectId}
tools={[]}
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={initialToolkitSlug}
filterByTriggers={true}
/>
)}
{selectedToolkit && !selectedTriggerType && (
<ComposioTriggerTypesPanel
key={selectedToolkit.slug}
toolkit={selectedToolkit}
onBack={() => setSelectedToolkit(null)}
onSelectTriggerType={handleSelectTriggerType}
initialTriggerTypeSlug={pendingTriggerTypeSlug}
/>
)}
{selectedToolkit && selectedTriggerType && (!requiresAuth || hasActiveConnection) && (
<div className="space-y-4">
<div>
<Button variant="light" size="sm" onPress={() => setSelectedTriggerType(null)}>
Back
</Button>
</div>
<TriggerConfigForm
toolkit={selectedToolkit}
triggerType={selectedTriggerType}
onBack={() => setSelectedTriggerType(null)}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
initialConfig={initialConfig}
/>
</div>
)}
{selectedToolkit && selectedTriggerType && requiresAuth && !hasActiveConnection && !showAuthModal && (
<div className="py-12 text-center space-y-4">
<Spinner className="mx-auto" />
<p className="text-sm text-gray-600 dark:text-gray-300">
Waiting for authentication to complete...
</p>
</div>
)}
{error && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-3 text-sm text-red-600 dark:text-red-300">
{error}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
{selectedToolkit && (
<ToolkitAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
projectId={projectId}
toolkitSlug={selectedToolkit.slug}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -29,6 +29,7 @@ export function Action({
onApplied,
externallyApplied = false,
defaultExpanded = false,
onRequestTriggerSetup,
}: {
msgIndex: number;
actionIndex: number;
@ -39,10 +40,12 @@ export function Action({
onApplied?: () => void;
externallyApplied?: boolean;
defaultExpanded?: boolean;
onRequestTriggerSetup?: (params: { action: z.infer<typeof CopilotAssistantMessageActionPart>['content']; msgIndex: number; actionIndex: number }) => void;
}) {
const { showPreview } = usePreviewModal();
const [expanded, setExpanded] = useState(defaultExpanded);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
const isExternalTriggerCreate = action.config_type === 'external_trigger' && action.action === 'create_new';
if (!action || typeof action !== 'object') {
console.warn('Invalid action object:', action);
@ -108,6 +111,10 @@ export function Action({
// Handle applying all changes - delegate to parent
const handleApplyAll = () => {
if (isExternalTriggerCreate) {
onRequestTriggerSetup?.({ action, msgIndex, actionIndex });
return;
}
// Mark all fields as applied locally for UI state
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
acc[getAppliedChangeKey(msgIndex, actionIndex, key)] = true;
@ -230,9 +237,9 @@ export function Action({
onClick={() => handleApplyAll()}
>
<CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} />
<span>{allApplied ? 'Applied' : 'Apply'}</span>
<span>{allApplied ? 'Applied' : isExternalTriggerCreate ? 'Open setup' : 'Apply'}</span>
</button>
{action.action !== 'delete' && <button
{action.action !== 'delete' && !isExternalTriggerCreate && <button
className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors"
onClick={handleViewDiff}
>

View file

@ -7,6 +7,7 @@ import MarkdownContent from "@/app/lib/components/markdown-content";
import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, ChevronUp } from "lucide-react";
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Action, StreamingAction } from './actions';
import { TriggerSetupModal } from './TriggerSetupModal';
import { useParsedBlocks } from "../use-parsed-blocks";
import { validateConfigChanges } from "@/app/lib/client_utils";
import { PreviewModalProvider } from '../../workflow/preview-modal';
@ -224,6 +225,14 @@ function AssistantMessage({
const triggersRef = useRef<CopilotTriggerType[] | undefined>(triggers);
const pendingTriggerEditsRef = useRef<Map<string, CopilotTriggerType>>(new Map());
const triggerUpdateCallbackRef = useRef<typeof onTriggersUpdated>(onTriggersUpdated);
const [triggerSetupModal, setTriggerSetupModal] = useState<{
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'];
actionIndex: number;
messageIndex: number;
initialToolkitSlug: string | null;
initialTriggerTypeSlug: string | null;
initialConfig?: Record<string, unknown>;
} | null>(null);
useEffect(() => {
triggersRef.current = triggers;
@ -234,6 +243,82 @@ function AssistantMessage({
triggerUpdateCallbackRef.current = onTriggersUpdated;
}, [onTriggersUpdated]);
const refreshTriggers = useCallback(async () => {
const callback = triggerUpdateCallbackRef.current;
if (!callback) {
return;
}
try {
await callback();
} catch (error) {
console.error('Failed to refresh triggers after Copilot action', error);
}
}, []);
const requestTriggerSetup = useCallback((params: {
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'];
actionIndex: number;
messageIndex: number;
}) => {
const { action, actionIndex, messageIndex: msgIndex } = params;
const changes = (action?.config_changes ?? {}) as Record<string, unknown>;
const toStringOrNull = (value: unknown): string | null => {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
return null;
};
const deriveSlug = (primary: unknown, secondary: unknown, tertiary: unknown): string | null => {
return toStringOrNull(primary) ?? toStringOrNull(secondary) ?? toStringOrNull(tertiary);
};
const toolkitSlug = deriveSlug(
changes.toolkitSlug,
changes.toolkit_slug,
typeof changes.toolkit === 'object' && changes.toolkit !== null ? (changes.toolkit as any).slug : changes.toolkit
);
const triggerTypeSlug = deriveSlug(
changes.triggerTypeSlug,
changes.trigger_type_slug,
typeof changes.triggerType === 'object' && changes.triggerType !== null ? (changes.triggerType as any).slug : changes.triggerType
);
const triggerConfigCandidate = (changes.triggerConfig ?? changes.trigger_config ?? changes.config) as unknown;
const triggerConfig = typeof triggerConfigCandidate === 'object' && triggerConfigCandidate !== null
? (triggerConfigCandidate as Record<string, unknown>)
: undefined;
setTriggerSetupModal(prev => {
if (prev && prev.actionIndex === actionIndex && prev.messageIndex === msgIndex) {
return prev;
}
return {
action,
actionIndex,
messageIndex: msgIndex,
initialToolkitSlug: toolkitSlug,
initialTriggerTypeSlug: triggerTypeSlug,
initialConfig: triggerConfig,
};
});
}, []);
const handleTriggerSetupCreated = useCallback(async () => {
if (!triggerSetupModal) {
return;
}
const index = triggerSetupModal.actionIndex;
setAppliedActions(prev => {
const next = new Set(prev);
next.add(index);
return next;
});
await refreshTriggers();
setTriggerSetupModal(null);
}, [refreshTriggers, triggerSetupModal]);
const handleTriggerSetupClosed = useCallback(() => {
setTriggerSetupModal(null);
}, []);
// parse actions from parts
const parsed = useMemo(() => {
const result: z.infer<typeof CopilotResponsePart>[] = [];
@ -616,6 +701,15 @@ function AssistantMessage({
}
}
if (configType === 'external_trigger') {
if (actionType === 'create_new') {
if (typeof actionIndex === 'number') {
requestTriggerSetup({ action, actionIndex, messageIndex });
}
return false;
}
}
if ((configType === 'external_trigger' || configType === 'external') && actionType === 'delete') {
const target = triggerList.find((trigger): trigger is Extract<CopilotTriggerType, { type: 'external' }> => {
if (trigger.type !== 'external') {
@ -646,19 +740,7 @@ function AssistantMessage({
console.warn('Unhandled trigger action from Copilot applyAction', action);
return false;
}, [projectId, parsed]);
const refreshTriggers = useCallback(async () => {
const callback = triggerUpdateCallbackRef.current;
if (!callback) {
return;
}
try {
await callback();
} catch (error) {
console.error('Failed to refresh triggers after Copilot action', error);
}
}, []);
}, [projectId, parsed, requestTriggerSetup, messageIndex]);
// Memoized handleApplyAll for useEffect dependencies
const handleApplyAll = useCallback(async () => {
@ -805,6 +887,7 @@ function AssistantMessage({
// Render all cards inline, not in a panel
return (
<>
<div className="w-full">
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
<div className="flex flex-col gap-2">
@ -827,6 +910,9 @@ function AssistantMessage({
onApplied={() => { void handleSingleApply(part.action, idx); }}
externallyApplied={appliedActions.has(idx)}
defaultExpanded={true}
onRequestTriggerSetup={({ action, actionIndex }) =>
requestTriggerSetup({ action, actionIndex, messageIndex })
}
/>
);
}
@ -845,6 +931,16 @@ function AssistantMessage({
</div>
</div>
</div>
<TriggerSetupModal
isOpen={Boolean(triggerSetupModal)}
onClose={handleTriggerSetupClosed}
projectId={projectId}
initialToolkitSlug={triggerSetupModal?.initialToolkitSlug ?? null}
initialTriggerTypeSlug={triggerSetupModal?.initialTriggerTypeSlug ?? null}
initialTriggerConfig={triggerSetupModal?.initialConfig}
onCreated={handleTriggerSetupCreated}
/>
</>
);
}

View file

@ -13,6 +13,7 @@ interface ComposioTriggerTypesPanelProps {
toolkit: z.infer<typeof ZToolkit>;
onBack: () => void;
onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void;
initialTriggerTypeSlug?: string | null;
}
type TriggerType = z.infer<typeof ComposioTriggerType>;
@ -21,6 +22,7 @@ export function ComposioTriggerTypesPanel({
toolkit,
onBack,
onSelectTriggerType,
initialTriggerTypeSlug,
}: ComposioTriggerTypesPanelProps) {
const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]);
const [loading, setLoading] = useState(true);
@ -28,6 +30,7 @@ export function ComposioTriggerTypesPanel({
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [autoSelected, setAutoSelected] = useState(false);
const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => {
try {
@ -70,8 +73,20 @@ export function ComposioTriggerTypesPanel({
useEffect(() => {
loadTriggerTypes(true);
setAutoSelected(false);
}, [loadTriggerTypes]);
useEffect(() => {
if (!initialTriggerTypeSlug || autoSelected || triggerTypes.length === 0) {
return;
}
const match = triggerTypes.find(triggerType => triggerType.slug === initialTriggerTypeSlug);
if (match) {
setAutoSelected(true);
onSelectTriggerType(match);
}
}, [initialTriggerTypeSlug, triggerTypes, onSelectTriggerType, autoSelected]);
if (loading) {
return (
<div className="space-y-6">

View file

@ -1,6 +1,6 @@
'use client';
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react';
import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react';
import { z } from 'zod';
@ -13,6 +13,7 @@ interface TriggerConfigFormProps {
onBack: () => void;
onSubmit: (config: Record<string, unknown>) => void;
isSubmitting?: boolean;
initialConfig?: Record<string, unknown>;
}
interface JsonSchemaProperty {
@ -36,13 +37,36 @@ export function TriggerConfigForm({
onBack,
onSubmit,
isSubmitting = false,
initialConfig,
}: TriggerConfigFormProps) {
const [formData, setFormData] = useState<Record<string, string>>({});
const [formData, setFormData] = useState<Record<string, string>>(() => {
if (!initialConfig) {
return {};
}
return Object.entries(initialConfig).reduce<Record<string, string>>((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value);
}
return acc;
}, {});
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Parse the JSON schema from triggerType.config
const schema = triggerType.config as JsonSchema;
useEffect(() => {
if (!initialConfig) {
return;
}
setFormData(Object.entries(initialConfig).reduce<Record<string, string>>((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value);
}
return acc;
}, {}));
}, [initialConfig, triggerType.slug]);
const handleSubmit = useCallback(() => {
// Validate required fields
const newErrors: Record<string, string> = {};