Feature/copilot trigger creation (#274)

* feat: Add Copilot trigger creation support

- Add support for One-Time and Recurring triggers in Copilot
- Extend CopilotAssistantMessageActionPart schema with trigger config types
- Update Copilot instructions with trigger creation examples and guidelines
- Implement trigger action handling in messages.tsx component
- Add trigger icons ( for one-time, 🔄 for recurring) in action cards
- Update workflow reducer to handle trigger creation via existing APIs
- Fix action parser to recognize trigger config types in comment format
- Add async trigger processing using createScheduledJobRule and createRecurringJobRule APIs

Users can now ask Copilot to create triggers with natural language requests like:
'Create a daily report trigger at 9 AM' or 'Set up a one-time reminder for next Friday'

* feat: Enhance Copilot message handling and trigger actions

- Pass projectId to Messages and AssistantMessage components for better context
- Refactor applyAction to handle one-time and recurring triggers with improved error handling
- Update handleApplyAll and handleSingleApply to support async action processing
- Remove deprecated pending trigger logic from workflow editor

This update improves the Copilot's ability to manage triggers and enhances the overall message processing flow.

* refactor: route trigger actions via copilot helper

Keep workflow reducer synchronous by removing trigger jobs from the switch and moving job rule API calls into a dedicated helper in messages.tsx. Cache dynamic imports and guard types so Copilot Apply/Apply All handle trigger creation without touching reducer state.

* feat: Add current time to the copilot context

* added conext of triggers to the copilot along with being able to edit and delete triggers

* bug fix for deleting composio triggers

* Add the edit function that allows editing triggers and lets copilot edit triggers too without losing previous jobs
feat: Add update functionality for recurring and scheduled job rules

- Implemented update actions for recurring job rules and scheduled job rules, allowing users to modify existing rules with new input and scheduling configurations.
- Enhanced the UI components to support editing of job rules, including forms for both creating and updating rules.
- Updated the repository interfaces and MongoDB implementations to handle the new update operations for job rules.

This update improves the flexibility of managing job rules within the application.

* Add trigger context to copilot
feat: Enhance trigger management in Copilot

- Added functionality to search for relevant external triggers using the new `search_relevant_triggers` tool, allowing users to discover available triggers based on toolkit slugs and optional query keywords.
- Updated the Copilot context to include detailed descriptions of various external trigger toolkits, enhancing user guidance for trigger creation.
- Improved the overall trigger handling process, ensuring that users can effectively integrate external triggers into their workflows.

This update significantly enhances the Copilot's capabilities in managing and utilizing external triggers.

* 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.

* External trigger cant be edited so we delete and recreate for this
feat: Improve external trigger handling in Copilot

- Added validation for editing external triggers, ensuring users are informed that existing triggers must be deleted and recreated for changes.
- Updated documentation to clarify the limitations of external trigger modifications.

This update enhances user experience by providing clear guidance on managing external triggers within the Copilot interface.

* preventing message.tsx from ballooning up in size
feat: Refactor Copilot message handling and trigger actions

- Removed deprecated logic for loading scheduled and recurring job actions, streamlining the trigger action process.
- Integrated `useCopilotTriggerActions` hook to manage trigger setup and actions more efficiently.
- Enhanced parsing of action parts to improve handling of triggers and their configurations.
- Updated the UI to reflect changes in action handling, ensuring a smoother user experience.

This update optimizes the Copilot's ability to manage triggers and enhances the overall message processing flow.

* refactor: Simplify trigger filtering in Copilot

- Removed unnecessary filtering logic for triggers based on user queries, streamlining the search process.
- Updated response messages to clarify the context of displayed triggers, enhancing user understanding.

This update improves the efficiency of the trigger search functionality within the Copilot interface.

* Revert "refactor: Simplify trigger filtering in Copilot"

This reverts commit b3d041677c.

* simplify the filtering logic for triggers in copilot
feat: Enhance external trigger creation and search functionality in Copilot

- Introduced a critical flow for adding external triggers, emphasizing minimal user input and UI configuration.
- Updated documentation to clarify the external trigger creation process and provided examples for better guidance.
- Simplified the trigger search logic, ensuring users receive relevant results while maintaining clarity in response messages.

This update improves the user experience by streamlining external trigger management and enhancing the search capabilities within the Copilot interface.

---------

Co-authored-by: tusharmagar <tushmag@gmail.com>
This commit is contained in:
Ramnique Singh 2025-10-16 12:03:56 +05:30 committed by GitHub
parent 96fd8b10ca
commit 476654af80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2787 additions and 495 deletions

View file

@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner, Tooltip } from "@heroui/react";
import { useRef, useState, createContext, useContext, useCallback, forwardRef, useImperativeHandle, useEffect, Ref } from "react";
import { CopilotChatContext } from "../../../../src/entities/models/copilot";
import { CopilotChatContext, TriggerSchemaForCopilot } from "../../../../src/entities/models/copilot";
import { CopilotMessage } from "../../../../src/entities/models/copilot";
import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source";
@ -36,6 +36,8 @@ interface AppProps {
onMessagesChange?: (messages: z.infer<typeof CopilotMessage>[]) => void;
isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
onTriggersUpdated?: () => Promise<void> | void;
}
const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message: string) => void }, AppProps>(function App({
@ -47,6 +49,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
onMessagesChange,
isInitialState = false,
dataSources,
triggers,
onTriggersUpdated,
}, ref) {
@ -85,7 +89,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
projectId,
workflow: workflowRef.current,
context: effectiveContext,
dataSources: dataSources
dataSources: dataSources,
triggers: triggers
});
// Store latest start/cancel functions in refs
@ -255,6 +260,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
</div>
)}
<Messages
projectId={projectId}
messages={messages}
streamingResponse={streamingResponse}
loadingResponse={loadingResponse}
@ -263,6 +269,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
onStatusBarChange={handleStatusBarChange}
toolCalling={toolCalling}
toolQuery={toolQuery}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
/>
</div>
<div className="shrink-0 px-0 pb-10">
@ -318,8 +326,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
dispatch: (action: WorkflowDispatch) => void;
isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
activePanel?: 'playground' | 'copilot';
onTogglePanel?: () => void;
onTriggersUpdated?: () => Promise<void> | void;
}>(({
projectId,
workflow,
@ -327,8 +337,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
dispatch,
isInitialState = false,
dataSources,
triggers,
activePanel,
onTogglePanel,
onTriggersUpdated,
}, ref) => {
console.log('🎪 Copilot wrapper component mounted:', {
projectId,
@ -414,6 +426,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
onMessagesChange={setMessages}
isInitialState={isInitialState}
dataSources={dataSources}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
/>
</div>
</Panel>

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;
@ -211,7 +218,7 @@ export function Action({
{action.config_type === 'tool' && toolkitLogo ? (
<PictureImg src={toolkitLogo} alt={"Toolkit logo"} className="h-5 w-5 object-contain" />
) : (
action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : '💬'
action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : action.config_type === 'one_time_trigger' ? '⏰' : action.config_type === 'recurring_trigger' ? '🔄' : action.config_type === 'external_trigger' ? '🔗' : '💬'
)}
</span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
@ -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}
>
@ -379,7 +386,7 @@ export function StreamingAction({
}: {
action: {
action?: 'create_new' | 'edit' | 'delete';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger';
name?: string;
};
loading: boolean;
@ -418,7 +425,7 @@ export function StreamingAction({
'bg-gray-200 text-gray-600': !action.action,
}
)}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : '💬'}
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'one_time_trigger' ? '⏰' : action.config_type === 'recurring_trigger' ? '🔄' : action.config_type === 'external_trigger' ? '🔗' : '💬'}
</span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
{action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name}
@ -444,4 +451,4 @@ export function StreamingAction({
</div>
</div>
);
}
}

View file

@ -5,12 +5,16 @@ import { z } from "zod";
import { Workflow} from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, ChevronUp } from "lucide-react";
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/src/entities/models/copilot";
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Action, StreamingAction } from './actions';
import { TriggerSetupModal } from './TriggerSetupModal';
import { useCopilotTriggerActions } from './use-trigger-actions';
import { useParsedBlocks } from "../use-parsed-blocks";
import { validateConfigChanges } from "@/app/lib/client_utils";
import { PreviewModalProvider } from '../../workflow/preview-modal';
type CopilotTriggerType = z.infer<typeof TriggerSchemaForCopilot>;
const CopilotResponsePart = z.union([
z.object({
type: z.literal('text'),
@ -71,7 +75,7 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: {},
@ -80,15 +84,27 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
};
}
const actionPayload = {
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
};
if (actionPayload.config_type === 'external_trigger' && actionPayload.action === 'edit') {
return {
type: 'action',
action: {
...actionPayload,
error: "Editing external triggers isn't supported. Delete the trigger and create a new one with the updated settings—I can take care of that for you if you'd like."
}
};
}
return {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
}
action: actionPayload
};
}
} catch (e) {
@ -100,7 +116,7 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
type: 'streaming_action',
action: {
action: (metadata.action as 'create_new' | 'edit' | 'delete') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger') || undefined,
name: metadata.name
}
};
@ -171,18 +187,23 @@ function AssistantMessage({
dispatch,
messageIndex,
loading,
onStatusBarChange
onStatusBarChange,
projectId,
triggers,
onTriggersUpdated,
}: {
content: z.infer<typeof CopilotAssistantMessage>['content'],
workflow: z.infer<typeof Workflow>,
dispatch: (action: any) => void,
messageIndex: number,
loading: boolean,
onStatusBarChange?: (status: any) => void
onStatusBarChange?: (status: any) => void;
projectId: string;
triggers?: CopilotTriggerType[];
onTriggersUpdated?: () => Promise<void> | void;
}) {
const blocks = useParsedBlocks(content);
const [appliedActions, setAppliedActions] = useState<Set<number>>(new Set());
// Remove autoApplyEnabled and useEffect for auto-apply
// parse actions from parts
const parsed = useMemo(() => {
@ -200,6 +221,46 @@ function AssistantMessage({
return result;
}, [blocks]);
const hasUpcomingReplacement = useCallback((candidate: z.infer<typeof CopilotAssistantMessageActionPart>['content'], currentIndex: number = -1) => {
return parsed.some((part, idx) =>
idx > currentIndex &&
part.type === 'action' &&
part.action.config_type === candidate.config_type &&
part.action.name === candidate.name &&
part.action.action === 'create_new'
);
}, [parsed]);
const {
triggerSetupModal,
requestTriggerSetup,
closeTriggerSetup,
handleTriggerCreatedViaModal,
handleTriggerAction,
} = useCopilotTriggerActions({
projectId,
triggers,
onTriggersUpdated,
hasUpcomingReplacement,
});
const handleTriggerSetupCreated = useCallback(async () => {
if (!triggerSetupModal) {
return;
}
const index = triggerSetupModal.actionIndex;
setAppliedActions(prev => {
const next = new Set(prev);
next.add(index);
return next;
});
await handleTriggerCreatedViaModal();
}, [handleTriggerCreatedViaModal, triggerSetupModal]);
const handleTriggerSetupClosed = useCallback(() => {
closeTriggerSetup();
}, [closeTriggerSetup]);
// Count action cards for tracking
const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action');
const totalActions = parsed.filter(part => part.type === 'action').length;
@ -208,14 +269,12 @@ function AssistantMessage({
const allApplied = pendingCount === 0 && totalActions > 0;
// Memoized applyAction for useCallback dependencies
const applyAction = useCallback((action: any, actionIndex: number) => {
// Only apply, do not update appliedActions here
const applyAction = useCallback((action: any): boolean => {
if (action.action === 'create_new') {
switch (action.config_type) {
case 'agent': {
// Prevent duplicate agent names
if (workflow.agents.some((agent: any) => agent.name === action.name)) {
return;
return false;
}
dispatch({
type: 'add_agent',
@ -225,12 +284,11 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
}
case 'tool': {
// Prevent duplicate tool names
if (workflow.tools.some((tool: any) => tool.name === action.name)) {
return;
return false;
}
dispatch({
type: 'add_tool',
@ -240,7 +298,7 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
}
case 'prompt':
dispatch({
@ -251,7 +309,7 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
case 'pipeline':
dispatch({
type: 'add_pipeline',
@ -261,7 +319,7 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
}
} else if (action.action === 'edit') {
switch (action.config_type) {
@ -271,34 +329,34 @@ function AssistantMessage({
name: action.name,
agent: action.config_changes
});
break;
return true;
case 'tool':
dispatch({
type: 'update_tool_no_select',
name: action.name,
tool: action.config_changes
});
break;
return true;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: action.config_changes
});
break;
return true;
case 'pipeline':
dispatch({
type: 'update_pipeline',
name: action.name,
pipeline: action.config_changes
});
break;
return true;
case 'start_agent':
dispatch({
type: 'set_main_agent',
name: action.name,
})
break;
});
return true;
}
} else if (action.action === 'delete') {
switch (action.config_type) {
@ -307,72 +365,93 @@ function AssistantMessage({
type: 'delete_agent',
name: action.name
});
break;
return true;
case 'tool':
dispatch({
type: 'delete_tool',
name: action.name
});
break;
return true;
case 'prompt':
dispatch({
type: 'delete_prompt',
name: action.name
});
break;
return true;
case 'pipeline':
dispatch({
type: 'delete_pipeline',
name: action.name
});
break;
return true;
}
}
console.warn('Unhandled action from Copilot applyAction', action);
return false;
}, [dispatch, workflow.agents, workflow.tools]);
// Memoized handleApplyAll for useEffect dependencies
const handleApplyAll = useCallback(() => {
// Find all unapplied action indices
const unapplied = parsed
.map((part, idx) => ({ part, actionIndex: idx }))
.filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex))
.map(({ part, actionIndex }) => ({
action: part.type === 'action' ? part.action : null,
actionIndex
}))
.filter(({ action }) => action !== null);
const handleApplyAll = useCallback(async () => {
const unapplied = parsed.reduce<Array<{ action: z.infer<typeof CopilotAssistantMessageActionPart>['content']; actionIndex: number }>>((acc, part, idx) => {
if (part.type === 'action' && !appliedActions.has(idx)) {
acc.push({ action: part.action, actionIndex: idx });
}
return acc;
}, []);
// Synchronously apply all unapplied actions
unapplied.forEach(({ action, actionIndex }) => {
applyAction(action, actionIndex);
});
const newlyApplied: number[] = [];
// After all are applied, update the state in one go
setAppliedActions(prev => {
const next = new Set(prev);
unapplied.forEach(({ actionIndex }) => next.add(actionIndex));
return next;
});
}, [parsed, appliedActions, setAppliedActions, applyAction]);
for (const { action, actionIndex } of unapplied) {
try {
const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
const success = isTrigger
? await handleTriggerAction(action, { actionIndex, messageIndex })
: applyAction(action);
if (success) {
newlyApplied.push(actionIndex);
}
} catch (error) {
console.error('Failed to apply Copilot action', action, error);
}
}
if (newlyApplied.length > 0) {
setAppliedActions(prev => {
const next = new Set(prev);
newlyApplied.forEach(index => next.add(index));
return next;
});
}
}, [parsed, appliedActions, applyAction, handleTriggerAction, messageIndex]);
// Manual single apply (from card)
const handleSingleApply = (action: any, actionIndex: number) => {
if (!appliedActions.has(actionIndex)) {
applyAction(action, actionIndex);
setAppliedActions(prev => new Set([...prev, actionIndex]));
const handleSingleApply = useCallback(async (action: z.infer<typeof CopilotAssistantMessageActionPart>['content'], actionIndex: number) => {
if (appliedActions.has(actionIndex)) {
return;
}
};
try {
const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
const success = isTrigger
? await handleTriggerAction(action, { actionIndex, messageIndex })
: applyAction(action);
if (success) {
setAppliedActions(prev => new Set([...prev, actionIndex]));
}
} catch (error) {
console.error('Failed to apply Copilot action', action, error);
}
}, [appliedActions, applyAction, handleTriggerAction, messageIndex]);
useEffect(() => {
if (loading) {
// setAutoApplyEnabled(false); // Removed
setAppliedActions(new Set());
// setPanelOpen(false); // Removed
}
}, [loading]);
// Removed useEffect for auto-apply
// Find streaming/ongoing card and extract name
const streamingPart = parsed.find(part => part.type === 'streaming_action');
let streamingLine = '';
@ -389,8 +468,8 @@ function AssistantMessage({
const createCount = parsed.filter(part => part.type === 'action' && part.action.action === 'create_new').length;
const editCount = parsed.filter(part => part.type === 'action' && part.action.action === 'edit').length;
const parts = [];
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`);
if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`);
if (createCount > 0) parts.push(`${createCount} item${createCount > 1 ? 's' : ''} created`);
if (editCount > 0) parts.push(`${editCount} item${editCount > 1 ? 's' : ''} updated`);
completedSummary = parts.join(', ');
}
@ -412,9 +491,6 @@ function AssistantMessage({
}
// At the end of the render, call onStatusBarChange with the current status bar props
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
// Only call onStatusBarChange if the serializable status actually changes
const lastStatusRef = useRef<any>(null);
useEffect(() => {
@ -442,6 +518,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">
@ -461,9 +538,12 @@ function AssistantMessage({
workflow={workflow}
dispatch={dispatch}
stale={false}
onApplied={() => handleSingleApply(part.action, idx)}
onApplied={() => { void handleSingleApply(part.action, idx); }}
externallyApplied={appliedActions.has(idx)}
defaultExpanded={true}
onRequestTriggerSetup={({ action, actionIndex }) =>
requestTriggerSetup({ action, actionIndex, messageIndex })
}
/>
);
}
@ -482,6 +562,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}
/>
</>
);
}
@ -506,6 +596,7 @@ function AssistantMessageLoading({ currentStatus }: { currentStatus: 'thinking'
}
export function Messages({
projectId,
messages,
streamingResponse,
loadingResponse,
@ -513,8 +604,11 @@ export function Messages({
dispatch,
onStatusBarChange,
toolCalling,
toolQuery
toolQuery,
triggers,
onTriggersUpdated,
}: {
projectId: string;
messages: z.infer<typeof CopilotMessage>[];
streamingResponse: string;
loadingResponse: boolean;
@ -523,6 +617,8 @@ export function Messages({
onStatusBarChange?: (status: any) => void;
toolCalling?: boolean;
toolQuery?: string | null;
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
onTriggersUpdated?: () => Promise<void> | void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [displayMessages, setDisplayMessages] = useState(messages);
@ -551,9 +647,6 @@ export function Messages({
return () => clearTimeout(timeoutId);
}, [messages, loadingResponse]);
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => {
if (message.role === 'assistant') {
return (
@ -564,10 +657,12 @@ export function Messages({
dispatch={dispatch}
messageIndex={messageIndex}
loading={loadingResponse}
projectId={projectId}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
onStatusBarChange={status => {
// Only update for the last assistant message
if (messageIndex === displayMessages.length - 1) {
latestStatusBar.current = status;
onStatusBarChange?.(status);
}
}}
@ -603,4 +698,4 @@ export function Messages({
<div ref={messagesEndRef} />
</div>
);
}
}

View file

@ -0,0 +1,465 @@
'use client';
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Message } from "@/app/lib/types/types";
type ScheduledJobActionsModule = typeof import('@/app/actions/scheduled-job-rules.actions');
type RecurringJobActionsModule = typeof import('@/app/actions/recurring-job-rules.actions');
type ComposioActionsModule = typeof import('@/app/actions/composio.actions');
type CopilotTrigger = z.infer<typeof TriggerSchemaForCopilot>;
type CopilotAction = z.infer<typeof CopilotAssistantMessageActionPart>['content'];
export interface TriggerSetupModalState {
action: CopilotAction;
actionIndex: number;
messageIndex: number;
initialToolkitSlug: string | null;
initialTriggerTypeSlug: string | null;
initialConfig?: Record<string, unknown>;
}
interface UseCopilotTriggerActionsParams {
projectId: string;
triggers?: CopilotTrigger[];
onTriggersUpdated?: () => Promise<void> | void;
hasUpcomingReplacement: (action: CopilotAction, currentIndex?: number) => boolean;
}
interface UseCopilotTriggerActionsResult {
triggerSetupModal: TriggerSetupModalState | null;
requestTriggerSetup: (params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => void;
closeTriggerSetup: () => void;
handleTriggerCreatedViaModal: () => Promise<void>;
handleTriggerAction: (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => Promise<boolean>;
}
let scheduledJobActionsPromise: Promise<ScheduledJobActionsModule> | null = null;
let recurringJobActionsPromise: Promise<RecurringJobActionsModule> | null = null;
let composioActionsPromise: Promise<ComposioActionsModule> | null = null;
function loadScheduledJobActions(): Promise<ScheduledJobActionsModule> {
if (!scheduledJobActionsPromise) {
scheduledJobActionsPromise = import('@/app/actions/scheduled-job-rules.actions');
}
return scheduledJobActionsPromise;
}
function loadRecurringJobActions(): Promise<RecurringJobActionsModule> {
if (!recurringJobActionsPromise) {
recurringJobActionsPromise = import('@/app/actions/recurring-job-rules.actions');
}
return recurringJobActionsPromise;
}
function loadComposioActions(): Promise<ComposioActionsModule> {
if (!composioActionsPromise) {
composioActionsPromise = import('@/app/actions/composio.actions');
}
return composioActionsPromise;
}
const hasOwn = (obj: Record<string, unknown> | undefined, key: string) =>
!!obj && Object.prototype.hasOwnProperty.call(obj, key);
const buildTriggerKey = (configType: string, name: string) => `${configType}:${name}`;
const toStringOrNull = (value: unknown): string | null => {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
return null;
};
const extractSlug = (primary: unknown, secondary: unknown, tertiary: unknown): string | null => {
return (
toStringOrNull(primary) ??
toStringOrNull(secondary) ??
(typeof tertiary === 'object' && tertiary !== null ? toStringOrNull((tertiary as { slug?: unknown }).slug) : toStringOrNull(tertiary))
);
};
const TriggerInputSchema = z.object({
messages: z.array(Message),
});
type TriggerInput = z.infer<typeof TriggerInputSchema>;
const coerceTriggerInput = (value: unknown, fallback?: TriggerInput | null): TriggerInput | null => {
if (value) {
const parsed = TriggerInputSchema.safeParse(value);
if (parsed.success) {
return parsed.data;
}
}
return fallback ?? null;
};
const extractTriggerSetupState = (
params: { action: CopilotAction; actionIndex: number; messageIndex: number }
): TriggerSetupModalState => {
const { action, actionIndex, messageIndex } = params;
const changes = (action?.config_changes ?? {}) as Record<string, unknown>;
const initialToolkitSlug = extractSlug(changes.toolkitSlug, changes.toolkit_slug, changes.toolkit);
const initialTriggerTypeSlug = extractSlug(changes.triggerTypeSlug, changes.trigger_type_slug, changes.triggerType);
const triggerConfigCandidate = (changes.triggerConfig ?? changes.trigger_config ?? changes.config) as unknown;
const initialConfig = typeof triggerConfigCandidate === 'object' && triggerConfigCandidate !== null
? (triggerConfigCandidate as Record<string, unknown>)
: undefined;
return {
action,
actionIndex,
messageIndex,
initialToolkitSlug,
initialTriggerTypeSlug,
initialConfig,
};
};
export function useCopilotTriggerActions({
projectId,
triggers,
onTriggersUpdated,
hasUpcomingReplacement,
}: UseCopilotTriggerActionsParams): UseCopilotTriggerActionsResult {
const [triggerSetupModal, setTriggerSetupModal] = useState<TriggerSetupModalState | null>(null);
const triggersRef = useRef<CopilotTrigger[]>(triggers ?? []);
const pendingTriggerEditsRef = useRef<Map<string, CopilotTrigger>>(new Map());
useEffect(() => {
triggersRef.current = triggers ?? [];
pendingTriggerEditsRef.current.clear();
}, [triggers]);
const refreshTriggers = useCallback(async () => {
if (!onTriggersUpdated) {
return;
}
await onTriggersUpdated();
}, [onTriggersUpdated]);
const requestTriggerSetup = useCallback((params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => {
setTriggerSetupModal(prev => {
if (prev && prev.actionIndex === params.actionIndex && prev.messageIndex === params.messageIndex) {
return prev;
}
return extractTriggerSetupState(params);
});
}, []);
const closeTriggerSetup = useCallback(() => {
setTriggerSetupModal(null);
}, []);
const handleTriggerCreatedViaModal = useCallback(async () => {
await refreshTriggers();
closeTriggerSetup();
}, [refreshTriggers, closeTriggerSetup]);
const handleOneTimeTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
const triggerList = triggersRef.current;
const key = buildTriggerKey(action.config_type, action.name);
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
let mutated = false;
const actionIndex = context?.actionIndex;
if (action.action === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
const { createScheduledJobRule, updateScheduledJobRule } = await loadScheduledJobActions();
if (pending && pending.type === 'one_time') {
const scheduledTime = (actionChanges.scheduledTime as string) ?? pending.nextRunAt;
const input = coerceTriggerInput(actionChanges.input, pending.input);
if (!scheduledTime || !input) {
console.error('Missing data for one-time trigger update via replacement', action);
return false;
}
await updateScheduledJobRule({
projectId,
ruleId: pending.id,
scheduledTime,
input,
});
pendingTriggerEditsRef.current.delete(key);
mutated = true;
} else {
const scheduledTime = actionChanges.scheduledTime as string | undefined;
const input = coerceTriggerInput(actionChanges.input);
if (!scheduledTime || !input) {
console.error('Missing scheduledTime or input for one-time trigger creation', action);
return false;
}
await createScheduledJobRule({
projectId,
scheduledTime,
input,
});
mutated = true;
}
return mutated;
}
const target = triggerList.find(
(trigger): trigger is Extract<CopilotTrigger, { type: 'one_time' }> =>
trigger.type === 'one_time' && trigger.name === action.name
);
if (!target) {
console.warn('Unable to resolve one-time trigger for action', action.name);
return false;
}
const {
fetchScheduledJobRule,
deleteScheduledJobRule,
updateScheduledJobRule,
} = await loadScheduledJobActions();
if (action.action === 'delete') {
if (hasUpcomingReplacement(action, actionIndex)) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteScheduledJobRule({ projectId, ruleId: target.id });
mutated = true;
return mutated;
}
if (action.action === 'edit') {
const existing = await fetchScheduledJobRule({ ruleId: target.id });
if (!existing) {
console.error('Failed to load existing one-time trigger for edit', action.name);
return false;
}
const scheduledTime = (actionChanges.scheduledTime as string) ?? existing.nextRunAt;
const input = coerceTriggerInput(actionChanges.input, existing.input);
if (!scheduledTime || !input) {
console.error('Missing data for one-time trigger edit', action);
return false;
}
await updateScheduledJobRule({
projectId,
ruleId: target.id,
scheduledTime,
input,
});
mutated = true;
}
return mutated;
}, [projectId, hasUpcomingReplacement]);
const handleRecurringTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
const triggerList = triggersRef.current;
const key = buildTriggerKey(action.config_type, action.name);
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
let mutated = false;
const actionIndex = context?.actionIndex;
const {
createRecurringJobRule,
updateRecurringJobRule,
toggleRecurringJobRule,
deleteRecurringJobRule,
fetchRecurringJobRule,
} = await loadRecurringJobActions();
if (action.action === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
if (pending && pending.type === 'recurring') {
const cron = (actionChanges.cron as string) ?? pending.cron;
const input = coerceTriggerInput(actionChanges.input, pending.input);
if (!cron || !input) {
console.error('Missing data for recurring trigger update via replacement', action);
return false;
}
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: pending.id,
cron,
input,
});
if (hasOwn(actionChanges, 'disabled')) {
const desired = typeof actionChanges.disabled === 'boolean'
? actionChanges.disabled
: pending.disabled;
if (typeof desired === 'boolean' && desired !== pending.disabled) {
await toggleRecurringJobRule({ ruleId: pending.id, disabled: desired });
}
}
pendingTriggerEditsRef.current.delete(key);
mutated = Boolean(updatedRule?.id);
} else {
const cron = actionChanges.cron as string | undefined;
const input = coerceTriggerInput(actionChanges.input);
if (!cron || !input) {
console.error('Missing cron or input for recurring trigger creation', action);
return false;
}
await createRecurringJobRule({
projectId,
cron,
input,
});
mutated = true;
}
return mutated;
}
const target = triggerList.find(
(trigger): trigger is Extract<CopilotTrigger, { type: 'recurring' }> =>
trigger.type === 'recurring' && trigger.name === action.name
);
if (!target) {
console.warn('Unable to resolve recurring trigger for action', action.name);
return false;
}
if (action.action === 'delete') {
if (hasUpcomingReplacement(action, actionIndex)) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteRecurringJobRule({ projectId, ruleId: target.id });
mutated = true;
return mutated;
}
if (action.action === 'edit') {
const existing = await fetchRecurringJobRule({ ruleId: target.id });
if (!existing) {
console.error('Failed to load existing recurring trigger for edit', action.name);
return false;
}
const desiredDisabled = typeof actionChanges.disabled === 'boolean'
? actionChanges.disabled
: existing.disabled;
const hasCronChange = hasOwn(actionChanges, 'cron');
const hasInputChange = hasOwn(actionChanges, 'input');
const hasDisabledToggle = hasOwn(actionChanges, 'disabled');
if (!hasCronChange && !hasInputChange && hasDisabledToggle) {
if (desiredDisabled !== existing.disabled) {
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
}
return true;
}
const cron = (actionChanges.cron as string) ?? existing.cron;
const input = coerceTriggerInput(actionChanges.input, existing.input);
if (!cron || !input) {
console.error('Missing data for recurring trigger edit', action);
return false;
}
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: target.id,
cron,
input,
});
if (hasDisabledToggle && desiredDisabled !== updatedRule.disabled) {
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
}
mutated = true;
}
return mutated;
}, [projectId, hasUpcomingReplacement]);
const handleExternalTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
if (action.action === 'create_new') {
const actionIndex = context?.actionIndex ?? -1;
const messageIndex = context?.messageIndex ?? -1;
requestTriggerSetup({ action, actionIndex, messageIndex });
return false;
}
if (action.action === 'delete') {
const triggerList = triggersRef.current;
const target = triggerList.find((trigger): trigger is Extract<CopilotTrigger, { type: 'external' }> => {
if (trigger.type !== 'external') {
return false;
}
const maybeName = (trigger as unknown as { name?: string }).name;
return (
trigger.triggerTypeName === action.name ||
trigger.triggerTypeSlug === action.name ||
trigger.id === action.name ||
maybeName === action.name
);
});
if (!target) {
console.warn('Unable to resolve external trigger for action', action.name);
return false;
}
const { deleteComposioTriggerDeployment } = await loadComposioActions();
await deleteComposioTriggerDeployment({ projectId, deploymentId: target.id });
return true;
}
return false;
}, [projectId, requestTriggerSetup]);
const handleTriggerAction = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
if (action.config_type === 'one_time_trigger') {
const mutated = await handleOneTimeTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
if (action.config_type === 'recurring_trigger') {
const mutated = await handleRecurringTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
if (action.config_type === 'external_trigger') {
const mutated = await handleExternalTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
return false;
}, [handleOneTimeTrigger, handleRecurringTrigger, handleExternalTrigger, refreshTriggers]);
return {
triggerSetupModal,
requestTriggerSetup,
closeTriggerSetup,
handleTriggerCreatedViaModal: handleTriggerCreatedViaModal,
handleTriggerAction,
};
}

View file

@ -3,6 +3,7 @@ import { getCopilotResponseStream } from "@/app/actions/copilot.actions";
import { CopilotMessage } from "@/src/entities/models/copilot";
import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { z } from "zod";
import { WithStringId } from "@/app/lib/types/types";
@ -11,6 +12,7 @@ interface UseCopilotParams {
workflow: z.infer<typeof Workflow>;
context: any;
dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
}
interface UseCopilotResult {
@ -29,7 +31,7 @@ interface UseCopilotResult {
cancel: () => void;
}
export function useCopilot({ projectId, workflow, context, dataSources }: UseCopilotParams): UseCopilotResult {
export function useCopilot({ projectId, workflow, context, dataSources, triggers }: UseCopilotParams): UseCopilotResult {
const [streamingResponse, setStreamingResponse] = useState('');
const [loading, setLoading] = useState(false);
const [toolCalling, setToolCalling] = useState(false);
@ -77,7 +79,7 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
// Wait 2 rAF frames to let layout stabilize (avoids StrictMode/remount race on initial load)
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources);
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources, triggers);
// Check for billing error
@ -139,7 +141,7 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
setLoading(false);
inFlightRef.current = false;
}
}, [projectId, workflow, context, dataSources]);
}, [projectId, workflow, context, dataSources, triggers]);
const cancel = useCallback(() => {
cancelRef.current?.();

View file

@ -1,12 +1,15 @@
'use client';
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { createRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { createRecurringJobRule, updateRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
// Define a simpler message type for the form that only includes the fields we need
type FormMessage = {
@ -14,6 +17,29 @@ type FormMessage = {
content: string;
};
type BackButtonConfig =
| { label: string; onClick: () => void }
| { label: string; href: string };
type FormSubmitPayload = {
messages: FormMessage[];
cron: string;
};
type RecurringJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialCron?: string;
initialMessages?: FormMessage[];
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const commonCronExamples = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every 5 minutes", value: "*/5 * * * *" },
@ -25,86 +51,112 @@ const commonCronExamples = [
{ label: "Monthly on the 1st at midnight", value: "0 0 1 * *" },
];
export function CreateRecurringJobRuleForm({
projectId,
onBack,
hasExistingTriggers = true
}: {
projectId: string;
onBack?: () => void;
hasExistingTriggers?: boolean;
}) {
const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
if (!messages || messages.length === 0) {
return [createEmptyMessage()];
}
return messages.map((message) => ({ ...message }));
};
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function RecurringJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialCron,
initialMessages,
onSubmit,
onSuccess,
successHref,
}: RecurringJobRuleFormBaseProps) {
const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [cronExpression, setCronExpression] = useState(initialCron ?? "* * * * *");
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
const [cronExpression, setCronExpression] = useState("* * * * *");
const [showCronHelp, setShowCronHelp] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setCronExpression(initialCron ?? "* * * * *");
}, [initialCron]);
const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]);
setMessages((prev) => [...prev, createEmptyMessage()]);
};
const removeMessage = (index: number) => {
if (messages.length > 1) {
setMessages(messages.filter((_, i) => i !== index));
}
setMessages((prev) => {
if (prev.length <= 1) {
return prev;
}
return prev.filter((_, i) => i !== index);
});
};
const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages];
newMessages[index] = { ...newMessages[index], [field]: value };
setMessages(newMessages);
setMessages((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate required fields
if (!cronExpression.trim()) {
alert("Please enter a cron expression");
return;
}
if (messages.some(msg => !msg.content?.trim())) {
if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content");
return;
}
setLoading(true);
try {
// Convert FormMessage to the expected Message type
const convertedMessages = messages.map(msg => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
});
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
const result = await onSubmit({
cron: cronExpression,
messages,
});
if (onBack) {
onBack();
} else {
router.push(`/projects/${projectId}/manage-triggers?tab=recurring`);
if (onSuccess) {
onSuccess(result);
} else if (successHref) {
router.push(successHref);
}
} catch (error) {
console.error("Failed to create recurring job rule:", error);
alert("Failed to create recurring job rule");
console.error(errorMessage, error);
alert(errorMessage);
} finally {
setLoading(false);
}
@ -114,30 +166,39 @@ export function CreateRecurringJobRuleForm({
<Panel
title={
<div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? (
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
onClick={onBack}
>
Back
</Button>
) : hasExistingTriggers ? (
<Link href={`/projects/${projectId}/manage-triggers?tab=recurring`}>
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
Back
{backButton ? (
'onClick' in backButton ? (
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
onClick={backButton.onClick}
>
{backButton.label}
</Button>
</Link>
) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE RECURRING JOB RULE
{title}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active.
</p>
{description ? (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
) : null}
</div>
</div>
}
@ -262,7 +323,7 @@ export function CreateRecurringJobRuleForm({
isLoading={loading}
className="px-6 py-2 whitespace-nowrap"
>
{loading ? "Creating..." : "Create Rule"}
{loading ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@ -271,3 +332,99 @@ export function CreateRecurringJobRuleForm({
</Panel>
);
}
export function CreateRecurringJobRuleForm({
projectId,
onBack,
hasExistingTriggers = true,
}: {
projectId: string;
onBack?: () => void;
hasExistingTriggers?: boolean;
}) {
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
cron,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=recurring` }
: undefined;
return (
<RecurringJobRuleFormBase
title="CREATE RECURRING JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create recurring job rule"
backButton={backButton}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=recurring`}
/>
);
}
export function EditRecurringJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof RecurringJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof RecurringJobRule>) => void;
}) {
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
cron,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof RecurringJobRule>);
}
onCancel();
};
return (
<RecurringJobRuleFormBase
title="EDIT RECURRING JOB RULE"
description="Update the cron schedule and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update recurring job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialCron={rule.cron}
initialMessages={initialMessages}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -5,12 +5,13 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { fetchRecurringJobRule, toggleRecurringJobRule, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon } from "lucide-react";
import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon, PencilIcon } from "lucide-react";
import Link from "next/link";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { Spinner } from "@heroui/react";
import { z } from "zod";
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
import { EditRecurringJobRuleForm } from "./create-recurring-job-rule-form";
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
const router = useRouter();
@ -19,6 +20,7 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
const jobsFilters = useMemo(() => ({ recurringJobRuleId: ruleId }), [ruleId]);
@ -145,128 +147,161 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
}
rightActions={
<div className="flex items-center gap-3">
<Button
onClick={handleToggleStatus}
disabled={updating}
variant={rule.disabled ? "primary" : "secondary"}
size="sm"
isLoading={updating}
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{rule.disabled ? 'Activate' : 'Pause'}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
{editing ? (
<Button
onClick={() => setEditing(false)}
variant="secondary"
size="sm"
className="whitespace-nowrap"
>
Cancel Edit
</Button>
) : (
<>
<Button
onClick={() => setEditing(true)}
variant="secondary"
size="sm"
startContent={<PencilIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
Edit
</Button>
<Button
onClick={handleToggleStatus}
disabled={updating}
variant={rule.disabled ? "primary" : "secondary"}
size="sm"
isLoading={updating}
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{rule.disabled ? 'Activate' : 'Pause'}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[800px] mx-auto space-y-6">
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
Status: {rule.disabled ? 'Disabled' : 'Active'}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
{editing ? (
<EditRecurringJobRuleForm
projectId={projectId}
rule={rule}
onCancel={() => setEditing(false)}
onUpdated={(updatedRule) => setRule(updatedRule)}
/>
</div>
) : (
<>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
Status: {rule.disabled ? 'Disabled' : 'Active'}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
/>
</div>
</>
)}
</div>
</div>
</Panel>

View file

@ -1,132 +1,197 @@
'use client';
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { createScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
import { createScheduledJobRule, updateScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { DatePicker } from "@heroui/react";
import { ZonedDateTime, now, getLocalTimeZone } from "@internationalized/date";
import { ZonedDateTime, now, getLocalTimeZone, parseAbsoluteToLocal } from "@internationalized/date";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
// Define a simpler message type for the form that only includes the fields we need
type FormMessage = {
role: "system" | "user" | "assistant";
content: string;
};
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
// Set default to 30 minutes from now with timezone info
const getDefaultDateTime = () => {
const localTimeZone = getLocalTimeZone();
const currentTime = now(localTimeZone);
const thirtyMinutesFromNow = currentTime.add({ minutes: 30 });
return thirtyMinutesFromNow;
};
type BackButtonConfig =
| { label: string; onClick: () => void }
| { label: string; href: string };
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(getDefaultDateTime());
type FormSubmitPayload = {
messages: FormMessage[];
scheduledDateTime: ZonedDateTime;
};
type ScheduledJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialMessages?: FormMessage[];
initialDateTime?: ZonedDateTime | null;
placeholderDateTime: ZonedDateTime;
minDateTime: ZonedDateTime;
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
if (!messages || messages.length === 0) {
return [createEmptyMessage()];
}
return messages.map((message) => ({ ...message }));
};
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function ScheduledJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialMessages,
initialDateTime,
placeholderDateTime,
minDateTime,
onSubmit,
onSuccess,
successHref,
}: ScheduledJobRuleFormBaseProps) {
const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(initialDateTime ?? placeholderDateTime);
const [loading, setLoading] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setScheduledDateTime(initialDateTime ?? placeholderDateTime);
}, [initialDateTime, placeholderDateTime]);
const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]);
setMessages((prev) => [...prev, createEmptyMessage()]);
};
const removeMessage = (index: number) => {
if (messages.length > 1) {
setMessages(messages.filter((_, i) => i !== index));
}
setMessages((prev) => {
if (prev.length <= 1) {
return prev;
}
return prev.filter((_, i) => i !== index);
});
};
const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages];
newMessages[index] = { ...newMessages[index], [field]: value };
setMessages(newMessages);
setMessages((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate required fields
if (!scheduledDateTime) {
alert("Please select date and time");
return;
}
if (messages.some(msg => !msg.content?.trim())) {
if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content");
return;
}
setLoading(true);
try {
// Convert FormMessage to the expected Message type
const convertedMessages = messages.map(msg => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
const result = await onSubmit({
messages,
scheduledDateTime,
});
// Convert ZonedDateTime to ISO string (already in UTC)
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
await createScheduledJobRule({
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
if (onBack) {
onBack();
} else {
router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`);
if (onSuccess) {
onSuccess(result);
} else if (successHref) {
router.push(successHref);
}
} catch (error) {
console.error("Failed to create scheduled job rule:", error);
alert("Failed to create scheduled job rule");
console.error(errorMessage, error);
alert(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Panel
title={
<div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? (
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap" onClick={onBack}>
Back
</Button>
) : hasExistingTriggers ? (
<Link href={`/projects/${projectId}/manage-triggers?tab=scheduled`}>
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
Back
{backButton ? (
'onClick' in backButton ? (
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
onClick={backButton.onClick}
>
{backButton.label}
</Button>
</Link>
) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE SCHEDULED JOB RULE
{title}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active.
</p>
{description ? (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
) : null}
</div>
</div>
}
@ -142,8 +207,8 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
<DatePicker
value={scheduledDateTime}
onChange={setScheduledDateTime}
placeholderValue={getDefaultDateTime()}
minValue={now(getLocalTimeZone())}
placeholderValue={placeholderDateTime}
minValue={minDateTime}
granularity="minute"
isRequired
className="w-full"
@ -214,7 +279,7 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
isLoading={loading}
className="px-6 py-2 whitespace-nowrap"
>
{loading ? "Creating..." : "Create Rule"}
{loading ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@ -223,3 +288,111 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
</Panel>
);
}
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const minDateTime = useMemo(() => now(timeZone), [timeZone]);
const defaultDateTime = useMemo(() => now(timeZone).add({ minutes: 30 }), [timeZone]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
await createScheduledJobRule({
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=scheduled` }
: undefined;
return (
<ScheduledJobRuleFormBase
title="CREATE SCHEDULED JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create scheduled job rule"
backButton={backButton}
initialDateTime={defaultDateTime}
placeholderDateTime={defaultDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=scheduled`}
/>
);
}
export function EditScheduledJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof ScheduledJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof ScheduledJobRule>) => void;
}) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const initialDateTime = useMemo(() => parseAbsoluteToLocal(rule.nextRunAt), [rule.nextRunAt]);
const nowDateTime = useMemo(() => now(timeZone), [timeZone]);
const minDateTime = useMemo(() => {
return initialDateTime.compare(nowDateTime) < 0 ? initialDateTime : nowDateTime;
}, [initialDateTime, nowDateTime]);
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
const updatedRule = await updateScheduledJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof ScheduledJobRule>);
}
onCancel();
};
return (
<ScheduledJobRuleFormBase
title="EDIT SCHEDULED JOB RULE"
description="Update the scheduled run time and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update scheduled job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialMessages={initialMessages}
initialDateTime={initialDateTime}
placeholderDateTime={initialDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -9,8 +9,9 @@ import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { z } from "zod";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeftIcon, Trash2Icon } from "lucide-react";
import { ArrowLeftIcon, Trash2Icon, PencilIcon } from "lucide-react";
import { MessageDisplay } from "@/app/lib/components/message-display";
import { EditScheduledJobRuleForm } from "./create-scheduled-job-rule-form";
export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string; }) {
const router = useRouter();
@ -18,6 +19,7 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
const [loading, setLoading] = useState<boolean>(true);
const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
let ignore = false;
@ -92,15 +94,37 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
}
rightActions={
<div className="flex items-center gap-3">
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
{editing ? (
<Button
onClick={() => setEditing(false)}
variant="secondary"
size="sm"
className="whitespace-nowrap"
>
Cancel Edit
</Button>
) : (
<>
<Button
onClick={() => setEditing(true)}
variant="secondary"
size="sm"
startContent={<PencilIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
Edit
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div>
}
>
@ -114,74 +138,85 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
)}
{!loading && rule && (
<div className="flex flex-col gap-6">
{/* Rule Metadata */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}>
{getStatusText(rule.status, rule.processedAt || null)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.nextRunAt)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
{editing ? (
<EditScheduledJobRuleForm
projectId={projectId}
rule={rule}
onCancel={() => setEditing(false)}
onUpdated={(updatedRule) => setRule(updatedRule)}
/>
) : (
<>
{/* Rule Metadata */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span>
</div>
)}
{rule.output?.jobId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
<Link
href={`/projects/${projectId}/jobs/${rule.output.jobId}`}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{rule.output.jobId}
</Link>
</span>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}>
{getStatusText(rule.status, rule.processedAt || null)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.nextRunAt)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
</div>
)}
{rule.output?.jobId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
<Link
href={`/projects/${projectId}/jobs/${rule.output.jobId}`}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{rule.output.jobId}
</Link>
</span>
</div>
)}
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div>
)}
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div>
</div>
</div>
{/* Messages */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Messages
</h3>
<div className="space-y-4">
{rule.input.messages.map((message, index) => (
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<MessageDisplay message={message} index={index} />
{/* Messages */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Messages
</h3>
<div className="space-y-4">
{rule.input.messages.map((message, index) => (
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<MessageDisplay message={message} index={index} />
</div>
))}
</div>
))}
</div>
</div>
</div>
</>
)}
</div>
)}
</div>

View file

@ -1,5 +1,6 @@
"use client";
import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Project } from "@/src/entities/models/project";
import { z } from "zod";
import { useCallback, useEffect, useState } from "react";
@ -10,10 +11,15 @@ import { revertToLiveWorkflow } from "@/app/actions/project.actions";
import { fetchProject } from "@/app/actions/project.actions";
import { Workflow } from "@/app/lib/types/workflow_types";
import { ModelsResponse } from "@/app/lib/types/billing_types";
import { listScheduledJobRules } from "@/app/actions/scheduled-job-rules.actions";
import { listRecurringJobRules } from "@/app/actions/recurring-job-rules.actions";
import { listComposioTriggerDeployments } from "@/app/actions/composio.actions";
import { transformTriggersForCopilot, DEFAULT_TRIGGER_FETCH_LIMIT } from "./trigger-transform";
export function App({
initialProjectData,
initialDataSources,
initialTriggers,
eligibleModels,
useRag,
useRagUploads,
@ -24,6 +30,7 @@ export function App({
}: {
initialProjectData: z.infer<typeof Project>;
initialDataSources: z.infer<typeof DataSource>[];
initialTriggers: z.infer<typeof TriggerSchemaForCopilot>[];
eligibleModels: z.infer<typeof ModelsResponse> | "*";
useRag: boolean;
useRagUploads: boolean;
@ -44,6 +51,7 @@ export function App({
});
const [project, setProject] = useState<z.infer<typeof Project>>(initialProjectData);
const [dataSources, setDataSources] = useState<z.infer<typeof DataSource>[]>(initialDataSources);
const [triggers, setTriggers] = useState<z.infer<typeof TriggerSchemaForCopilot>[]>(initialTriggers);
const [loading, setLoading] = useState(false);
console.log('workflow app.tsx render');
@ -65,21 +73,42 @@ export function App({
workflow = mode === 'live' ? project?.liveWorkflow : project?.draftWorkflow;
}
const reloadData = useCallback(async () => {
setLoading(true);
const [
projectData,
sourcesData,
] = await Promise.all([
fetchProject(initialProjectData.id),
listDataSources(initialProjectData.id),
const fetchTriggers = useCallback(async () => {
const [scheduled, recurring, composio] = await Promise.all([
listScheduledJobRules({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
listRecurringJobRules({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
listComposioTriggerDeployments({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
]);
setProject(projectData);
setDataSources(sourcesData);
setLoading(false);
return transformTriggersForCopilot({
scheduled: scheduled.items ?? [],
recurring: recurring.items ?? [],
composio: composio.items ?? [],
});
}, [initialProjectData.id]);
const refreshTriggers = useCallback(async () => {
const nextTriggers = await fetchTriggers();
setTriggers(nextTriggers);
}, [fetchTriggers]);
const reloadData = useCallback(async () => {
setLoading(true);
try {
const [projectData, sourcesData, triggerData] = await Promise.all([
fetchProject(initialProjectData.id),
listDataSources(initialProjectData.id),
fetchTriggers(),
]);
setProject(projectData);
setDataSources(sourcesData);
setTriggers(triggerData);
} finally {
setLoading(false);
}
}, [fetchTriggers, initialProjectData.id]);
const handleProjectToolsUpdate = useCallback(async () => {
// Lightweight refresh for tool-only updates
const projectConfig = await fetchProject(initialProjectData.id);
@ -133,8 +162,12 @@ export function App({
async function handleRevertToLive() {
setLoading(true);
await revertToLiveWorkflow(initialProjectData.id);
reloadData();
try {
await revertToLiveWorkflow(initialProjectData.id);
await reloadData();
} finally {
setLoading(false);
}
}
// if workflow is null, show the selector
@ -152,6 +185,7 @@ export function App({
onToggleAutoPublish={handleToggleAutoPublish}
workflow={workflow}
dataSources={dataSources}
triggers={triggers}
projectConfig={project}
useRag={useRag}
useRagUploads={useRagUploads}
@ -164,6 +198,7 @@ export function App({
onProjectToolsUpdated={handleProjectToolsUpdate}
onDataSourcesUpdated={handleDataSourcesUpdate}
onProjectConfigUpdated={handleProjectConfigUpdate}
onTriggersUpdated={refreshTriggers}
chatWidgetHost={chatWidgetHost}
/>}
</>

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">
@ -215,4 +230,4 @@ export function ComposioTriggerTypesPanel({
)}
</div>
);
}
}

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> = {};
@ -267,4 +291,4 @@ export function TriggerConfigForm({
</div>
</div>
);
}
}

View file

@ -9,10 +9,17 @@ import { ModelsResponse } from "@/app/lib/types/billing_types";
import { requireAuth } from "@/app/lib/auth";
import { IFetchProjectController } from "@/src/interface-adapters/controllers/projects/fetch-project.controller";
import { IListDataSourcesController } from "@/src/interface-adapters/controllers/data-sources/list-data-sources.controller";
import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { IListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller";
import { IListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { z } from "zod";
import { transformTriggersForCopilot, DEFAULT_TRIGGER_FETCH_LIMIT } from "./trigger-transform";
const fetchProjectController = container.resolve<IFetchProjectController>('fetchProjectController');
const listDataSourcesController = container.resolve<IListDataSourcesController>('listDataSourcesController');
const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController');
const listRecurringJobRulesController = container.resolve<IListRecurringJobRulesController>('listRecurringJobRulesController');
const listComposioTriggerDeploymentsController = container.resolve<IListComposioTriggerDeploymentsController>('listComposioTriggerDeploymentsController');
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
@ -39,23 +46,50 @@ export default async function Page(
notFound();
}
const sources = await listDataSourcesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
});
const [sources, scheduledTriggers, recurringTriggers, composioTriggers] = await Promise.all([
listDataSourcesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
}),
listScheduledJobRulesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
listRecurringJobRulesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
listComposioTriggerDeploymentsController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
]);
let eligibleModels: z.infer<typeof ModelsResponse> | "*" = '*';
if (USE_BILLING) {
eligibleModels = await getEligibleModels(customer.id);
}
const triggers = transformTriggersForCopilot({
scheduled: scheduledTriggers.items ?? [],
recurring: recurringTriggers.items ?? [],
composio: composioTriggers.items ?? [],
});
console.log('/workflow page.tsx serve');
return (
<App
initialProjectData={project}
initialDataSources={sources}
initialTriggers={triggers}
eligibleModels={eligibleModels}
useRag={USE_RAG}
useRagUploads={USE_RAG_UPLOADS}

View file

@ -0,0 +1,78 @@
import { z } from "zod";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Message } from "@/app/lib/types/types";
const COPILOT_TRIGGER_LIMIT = 100;
export const DEFAULT_TRIGGER_FETCH_LIMIT = COPILOT_TRIGGER_LIMIT;
export type CopilotTrigger = z.infer<typeof TriggerSchemaForCopilot>;
interface TransformParams {
scheduled: Array<{
id: string;
nextRunAt: string;
status: 'pending' | 'processing' | 'triggered';
input?: { messages: Array<z.infer<typeof Message>> };
}>;
recurring: Array<{
id: string;
cron: string;
nextRunAt: string | null;
disabled: boolean;
input?: { messages: Array<z.infer<typeof Message>> };
}>;
composio: Array<{
id: string;
triggerTypeName: string;
toolkitSlug: string;
triggerTypeSlug: string;
triggerConfig: Record<string, unknown>;
}>;
}
export function transformTriggersForCopilot({
scheduled,
recurring,
composio,
}: TransformParams): CopilotTrigger[] {
const placeholderInput = {
messages: [
{
role: "user" as const,
content: "Trigger execution",
},
],
} satisfies { messages: Array<z.infer<typeof Message>> };
const oneTime = scheduled.map((trigger) => ({
type: "one_time" as const,
id: trigger.id,
name: `One-time trigger (${new Date(trigger.nextRunAt).toLocaleDateString('en-US')})`,
nextRunAt: trigger.nextRunAt,
status: trigger.status,
input: trigger.input ?? placeholderInput,
}));
const recurringTriggers = recurring.map((trigger) => ({
type: "recurring" as const,
id: trigger.id,
name: `Recurring trigger (${trigger.cron})`,
cron: trigger.cron,
nextRunAt: trigger.nextRunAt ?? '',
disabled: trigger.disabled,
input: trigger.input ?? placeholderInput,
}));
const external = composio.map((trigger) => ({
type: "external" as const,
id: trigger.id,
name: trigger.triggerTypeName,
triggerTypeName: trigger.triggerTypeName,
toolkitSlug: trigger.toolkitSlug,
triggerTypeSlug: trigger.triggerTypeSlug,
triggerConfig: trigger.triggerConfig,
}));
return [...oneTime, ...recurringTriggers, ...external] as CopilotTrigger[];
}

View file

@ -3,6 +3,7 @@ import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, c
import { MCPServer, Message, WithStringId } from "../../../lib/types/types";
import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent, WorkflowPipeline } from "../../../lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Project } from "@/src/entities/models/project";
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
import { AgentConfig } from "../entities/agent_config";
@ -962,6 +963,7 @@ export function useEntitySelection() {
export function WorkflowEditor({
projectId,
dataSources,
triggers,
workflow,
useRag,
useRagUploads,
@ -978,10 +980,12 @@ export function WorkflowEditor({
onProjectToolsUpdated,
onDataSourcesUpdated,
onProjectConfigUpdated,
onTriggersUpdated,
chatWidgetHost,
}: {
projectId: string;
dataSources: z.infer<typeof DataSource>[];
triggers: z.infer<typeof TriggerSchemaForCopilot>[];
workflow: z.infer<typeof Workflow>;
useRag: boolean;
useRagUploads: boolean;
@ -998,6 +1002,7 @@ export function WorkflowEditor({
onProjectToolsUpdated?: () => void;
onDataSourcesUpdated?: () => void;
onProjectConfigUpdated?: () => void;
onTriggersUpdated?: () => Promise<void> | void;
chatWidgetHost: string;
}) {
@ -2313,8 +2318,10 @@ export function WorkflowEditor({
}
isInitialState={isInitialState}
dataSources={dataSources}
triggers={triggers}
activePanel={activePanel}
onTogglePanel={handleTogglePanel}
onTriggersUpdated={onTriggersUpdated}
/>
{/* Config overlay above Copilot when agents + skipper layout is active */}
{state.present.selection && viewMode === 'two_agents_skipper' && (