From 476654af80d3a12da97b27fd2142f2385c324023 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:03:56 +0530 Subject: [PATCH] Feature/copilot trigger creation (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 b3d041677c97075e3b70b6d9baec2243042bcd10. * 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 --- apps/rowboat/app/actions/composio.actions.ts | 4 +- apps/rowboat/app/actions/copilot.actions.ts | 5 +- .../actions/recurring-job-rules.actions.ts | 22 + .../actions/scheduled-job-rules.actions.ts | 24 +- apps/rowboat/app/lib/client_utils.ts | 35 ++ .../app/projects/[projectId]/copilot/app.tsx | 18 +- .../copilot/components/TriggerSetupModal.tsx | 237 +++++++++ .../copilot/components/actions.tsx | 19 +- .../copilot/components/messages.tsx | 245 ++++++--- .../copilot/components/use-trigger-actions.ts | 465 ++++++++++++++++++ .../[projectId]/copilot/use-copilot.tsx | 8 +- .../create-recurring-job-rule-form.tsx | 301 +++++++++--- .../components/recurring-job-rule-view.tsx | 269 +++++----- .../create-scheduled-job-rule-form.tsx | 327 +++++++++--- .../components/scheduled-job-rule-view.tsx | 183 ++++--- .../app/projects/[projectId]/workflow/app.tsx | 61 ++- .../components/ComposioTriggerTypesPanel.tsx | 17 +- .../workflow/components/TriggerConfigForm.tsx | 30 +- .../projects/[projectId]/workflow/page.tsx | 44 +- .../[projectId]/workflow/trigger-transform.ts | 78 +++ .../[projectId]/workflow/workflow_editor.tsx | 7 + .../components/common/compose-box-copilot.tsx | 2 +- apps/rowboat/di/container.ts | 10 +- .../src/application/lib/copilot/copilot.ts | 186 ++++++- .../lib/copilot/copilot_multi_agent_build.ts | 195 +++++++- .../lib/utils/is-valid-cron-expression.ts | 70 +++ ...ecurring-job-rules.repository.interface.ts | 19 + ...cheduled-job-rules.repository.interface.ts | 23 +- .../create-copilot-cached-turn.use-case.ts | 3 +- .../run-copilot-cached-turn.use-case.ts | 1 + .../create-recurring-job-rule.use-case.ts | 30 +- .../update-recurring-job-rule.use-case.ts | 69 +++ .../update-scheduled-job-rule.use-case.ts | 64 +++ apps/rowboat/src/entities/models/copilot.ts | 43 +- .../mongodb.recurring-job-rules.repository.ts | 27 +- .../mongodb.scheduled-job-rules.repository.ts | 37 +- .../create-copilot-cached-turn.controller.ts | 3 +- .../update-recurring-job-rule.controller.ts | 50 ++ .../update-scheduled-job-rule.controller.ts | 51 ++ 39 files changed, 2787 insertions(+), 495 deletions(-) create mode 100644 apps/rowboat/app/projects/[projectId]/copilot/components/TriggerSetupModal.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/copilot/components/use-trigger-actions.ts create mode 100644 apps/rowboat/app/projects/[projectId]/workflow/trigger-transform.ts create mode 100644 apps/rowboat/src/application/lib/utils/is-valid-cron-expression.ts create mode 100644 apps/rowboat/src/application/use-cases/recurring-job-rules/update-recurring-job-rule.use-case.ts create mode 100644 apps/rowboat/src/application/use-cases/scheduled-job-rules/update-scheduled-job-rule.use-case.ts create mode 100644 apps/rowboat/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller.ts create mode 100644 apps/rowboat/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller.ts diff --git a/apps/rowboat/app/actions/composio.actions.ts b/apps/rowboat/app/actions/composio.actions.ts index daddc930..8af20aaa 100644 --- a/apps/rowboat/app/actions/composio.actions.ts +++ b/apps/rowboat/app/actions/composio.actions.ts @@ -157,6 +157,7 @@ export async function createComposioTriggerDeployment(request: { export async function listComposioTriggerDeployments(request: { projectId: string, cursor?: string, + limit?: number, }) { const user = await authCheck(); @@ -166,6 +167,7 @@ export async function listComposioTriggerDeployments(request: { userId: user.id, projectId: request.projectId, cursor: request.cursor, + limit: request.limit, }); } @@ -191,4 +193,4 @@ export async function fetchComposioTriggerDeployment(request: { deploymentId: st userId: user.id, deploymentId: request.deploymentId, }); -} \ No newline at end of file +} diff --git a/apps/rowboat/app/actions/copilot.actions.ts b/apps/rowboat/app/actions/copilot.actions.ts index de9b1bae..e4d86731 100644 --- a/apps/rowboat/app/actions/copilot.actions.ts +++ b/apps/rowboat/app/actions/copilot.actions.ts @@ -3,6 +3,7 @@ import { CopilotAPIRequest, CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, + TriggerSchemaForCopilot, } from "../../src/entities/models/copilot"; import { Workflow} from "../lib/types/workflow_types"; @@ -26,7 +27,8 @@ export async function getCopilotResponseStream( messages: z.infer[], current_workflow_config: z.infer, context: z.infer | null, - dataSources?: z.infer[] + dataSources?: z.infer[], + triggers?: z.infer[] ): Promise<{ streamId: string; } | { billingError: string }> { @@ -42,6 +44,7 @@ export async function getCopilotResponseStream( workflow: current_workflow_config, context, dataSources, + triggers, } }); return { diff --git a/apps/rowboat/app/actions/recurring-job-rules.actions.ts b/apps/rowboat/app/actions/recurring-job-rules.actions.ts index d64264d8..b807aa3b 100644 --- a/apps/rowboat/app/actions/recurring-job-rules.actions.ts +++ b/apps/rowboat/app/actions/recurring-job-rules.actions.ts @@ -6,6 +6,7 @@ import { IListRecurringJobRulesController } from "@/src/interface-adapters/contr import { IFetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller"; import { IToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller"; import { IDeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller"; +import { IUpdateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller"; import { authCheck } from "./auth.actions"; import { z } from "zod"; import { Message } from "@/app/lib/types/types"; @@ -15,6 +16,7 @@ const listRecurringJobRulesController = container.resolve('fetchRecurringJobRuleController'); const toggleRecurringJobRuleController = container.resolve('toggleRecurringJobRuleController'); const deleteRecurringJobRuleController = container.resolve('deleteRecurringJobRuleController'); +const updateRecurringJobRuleController = container.resolve('updateRecurringJobRuleController'); export async function createRecurringJobRule(request: { projectId: string, @@ -89,3 +91,23 @@ export async function deleteRecurringJobRule(request: { ruleId: request.ruleId, }); } + +export async function updateRecurringJobRule(request: { + projectId: string, + ruleId: string, + input: { + messages: z.infer[], + }, + cron: string, +}) { + const user = await authCheck(); + + return await updateRecurringJobRuleController.execute({ + caller: 'user', + userId: user.id, + projectId: request.projectId, + ruleId: request.ruleId, + input: request.input, + cron: request.cron, + }); +} diff --git a/apps/rowboat/app/actions/scheduled-job-rules.actions.ts b/apps/rowboat/app/actions/scheduled-job-rules.actions.ts index 640c4e08..144475a0 100644 --- a/apps/rowboat/app/actions/scheduled-job-rules.actions.ts +++ b/apps/rowboat/app/actions/scheduled-job-rules.actions.ts @@ -5,6 +5,7 @@ import { ICreateScheduledJobRuleController } from "@/src/interface-adapters/cont import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller"; import { IFetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller"; import { IDeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller"; +import { IUpdateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller"; import { authCheck } from "./auth.actions"; import { z } from "zod"; import { Message } from "@/app/lib/types/types"; @@ -13,6 +14,7 @@ const createScheduledJobRuleController = container.resolve('listScheduledJobRulesController'); const fetchScheduledJobRuleController = container.resolve('fetchScheduledJobRuleController'); const deleteScheduledJobRuleController = container.resolve('deleteScheduledJobRuleController'); +const updateScheduledJobRuleController = container.resolve('updateScheduledJobRuleController'); export async function createScheduledJobRule(request: { projectId: string, @@ -72,4 +74,24 @@ export async function deleteScheduledJobRule(request: { projectId: request.projectId, ruleId: request.ruleId, }); -} \ No newline at end of file +} + +export async function updateScheduledJobRule(request: { + projectId: string, + ruleId: string, + input: { + messages: z.infer[], + }, + scheduledTime: string, +}) { + const user = await authCheck(); + + return await updateScheduledJobRuleController.execute({ + caller: 'user', + userId: user.id, + projectId: request.projectId, + ruleId: request.ruleId, + input: request.input, + scheduledTime: request.scheduledTime, + }); +} diff --git a/apps/rowboat/app/lib/client_utils.ts b/apps/rowboat/app/lib/client_utils.ts index d47545b2..2ea39915 100644 --- a/apps/rowboat/app/lib/client_utils.ts +++ b/apps/rowboat/app/lib/client_utils.ts @@ -1,4 +1,5 @@ import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types"; +import { Message } from "./types/types"; import { z } from "zod"; const ZFallbackSchema = z.object({}).passthrough(); @@ -62,6 +63,40 @@ export function validateConfigChanges(configType: string, configChanges: Record< testObject = {}; break; } + case 'one_time_trigger': { + testObject = { + scheduledTime: new Date(0).toISOString(), + input: { + messages: [], + }, + }; + schema = z.object({ + scheduledTime: z.string().min(1), + input: z.object({ + messages: z.array(Message), + }), + }).passthrough(); + break; + } + case 'recurring_trigger': { + testObject = { + cron: '* * * * *', + input: { + messages: [], + }, + }; + schema = z.object({ + cron: z.string().min(1), + input: z.object({ + messages: z.array(Message), + }), + }).passthrough(); + break; + } + case 'external_trigger': { + // External triggers have flexible schemas per provider; do not strip any config. + return { changes: configChanges }; + } default: return { error: `Unknown config type: ${configType}` }; } diff --git a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx index ca765952..f8d4eda3 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx @@ -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[]) => void; isInitialState?: boolean; dataSources?: z.infer[]; + triggers?: z.infer[]; + onTriggersUpdated?: () => Promise | 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 )} void; handleUserMessage: (message onStatusBarChange={handleStatusBarChange} toolCalling={toolCalling} toolQuery={toolQuery} + triggers={triggers} + onTriggersUpdated={onTriggersUpdated} />
@@ -318,8 +326,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void dispatch: (action: WorkflowDispatch) => void; isInitialState?: boolean; dataSources?: z.infer[]; + triggers?: z.infer[]; activePanel?: 'playground' | 'copilot'; onTogglePanel?: () => void; + onTriggersUpdated?: () => Promise | 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} />
diff --git a/apps/rowboat/app/projects/[projectId]/copilot/components/TriggerSetupModal.tsx b/apps/rowboat/app/projects/[projectId]/copilot/components/TriggerSetupModal.tsx new file mode 100644 index 00000000..db49d714 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/copilot/components/TriggerSetupModal.tsx @@ -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 | null; + onCreated?: () => void; +} + +type Toolkit = z.infer; +type TriggerType = z.infer; +type ProjectConfig = z.infer; + +export function TriggerSetupModal({ + isOpen, + onClose, + projectId, + initialToolkitSlug = null, + initialTriggerTypeSlug = null, + initialTriggerConfig = null, + onCreated, +}: TriggerSetupModalProps) { + const [selectedToolkit, setSelectedToolkit] = useState(null); + const [selectedTriggerType, setSelectedTriggerType] = useState(null); + const [projectConfig, setProjectConfig] = useState(null); + const [showAuthModal, setShowAuthModal] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [pendingTriggerTypeSlug, setPendingTriggerTypeSlug] = useState(null); + const [initialConfig, setInitialConfig] = useState | 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) => { + 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 ( + <> + + + +

Set up External Trigger

+

+ Follow the guided flow to authenticate and configure the trigger. +

+
+ + {!selectedToolkit && ( + + )} + + {selectedToolkit && !selectedTriggerType && ( + setSelectedToolkit(null)} + onSelectTriggerType={handleSelectTriggerType} + initialTriggerTypeSlug={pendingTriggerTypeSlug} + /> + )} + + {selectedToolkit && selectedTriggerType && (!requiresAuth || hasActiveConnection) && ( +
+
+ +
+ setSelectedTriggerType(null)} + onSubmit={handleSubmit} + isSubmitting={isSubmitting} + initialConfig={initialConfig} + /> +
+ )} + + {selectedToolkit && selectedTriggerType && requiresAuth && !hasActiveConnection && !showAuthModal && ( +
+ +

+ Waiting for authentication to complete... +

+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+
+ + {selectedToolkit && ( + setShowAuthModal(false)} + projectId={projectId} + toolkitSlug={selectedToolkit.slug} + onComplete={handleAuthComplete} + /> + )} + + ); +} diff --git a/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx b/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx index 55f950ae..c9c25a8b 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx @@ -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['content']; msgIndex: number; actionIndex: number }) => void; }) { const { showPreview } = usePreviewModal(); const [expanded, setExpanded] = useState(defaultExpanded); const [appliedChanges, setAppliedChanges] = useState>({}); + 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 ? ( ) : ( - 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' ? 'πŸ”—' : 'πŸ’¬' )} @@ -230,9 +237,9 @@ export function Action({ onClick={() => handleApplyAll()} > - {allApplied ? 'Applied' : 'Apply'} + {allApplied ? 'Applied' : isExternalTriggerCreate ? 'Open setup' : 'Apply'} - {action.action !== 'delete' && - ) : hasExistingTriggers ? ( - - - + ) : ( + + + + ) ) : null}
- CREATE RECURRING JOB RULE + {title}
-

- Note: Triggers run only on the published version of your workflow. Publish any changes to make them active. -

+ {description ? ( +

+ {description} +

+ ) : null}
} @@ -262,7 +323,7 @@ export function CreateRecurringJobRuleForm({ isLoading={loading} className="px-6 py-2 whitespace-nowrap" > - {loading ? "Creating..." : "Create Rule"} + {loading ? submittingLabel : submitLabel} @@ -271,3 +332,99 @@ export function CreateRecurringJobRuleForm({ ); } + +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 ( + + ); +} + +export function EditRecurringJobRuleForm({ + projectId, + rule, + onCancel, + onUpdated, +}: { + projectId: string; + rule: z.infer; + onCancel: () => void; + onUpdated?: (rule: z.infer) => void; +}) { + const initialMessages = useMemo(() => { + return rule.input.messages + .filter((message): message is Extract, { 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); + } + onCancel(); + }; + + return ( + + ); +} diff --git a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rule-view.tsx b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rule-view.tsx index 3b55cc31..a8413479 100644 --- a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rule-view.tsx +++ b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rule-view.tsx @@ -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={
- - + {editing ? ( + + ) : ( + <> + + + + + )}
} >
- {/* Status */} -
-
-
- - Status: {rule.disabled ? 'Disabled' : 'Active'} - -
- {rule.lastError && ( -
- -
- Last Error: {rule.lastError} -
-
- )} -
- - {/* Schedule Information */} -
-

- Schedule Information -

-
-
- - Cron Expression: - - {rule.cron} - -
-
- Human Readable: {formatCronExpression(rule.cron)} -
-
- Next Run: {formatDate(rule.nextRunAt)} -
- {rule.lastProcessedAt && ( -
- Last Processed: {formatDate(rule.lastProcessedAt)} -
- )} -
-
- - {/* Messages */} -
-

- Messages -

-
- {rule.input.messages.map((message, index) => ( -
-
- - {message.role.charAt(0).toUpperCase() + message.role.slice(1)} - -
-
- {message.content} -
-
- ))} -
-
- - {/* Metadata */} -
-

- Metadata -

-
-
Created: {formatDate(rule.createdAt)}
- {rule.updatedAt && ( -
Last Updated: {formatDate(rule.updatedAt)}
- )} -
Rule ID: {rule.id}
-
-
- - {/* Jobs Created by This Rule */} -
-

- Jobs Created by This Rule -

- setEditing(false)} + onUpdated={(updatedRule) => setRule(updatedRule)} /> -
+ ) : ( + <> + {/* Status */} +
+
+
+ + Status: {rule.disabled ? 'Disabled' : 'Active'} + +
+ {rule.lastError && ( +
+ +
+ Last Error: {rule.lastError} +
+
+ )} +
+ + {/* Schedule Information */} +
+

+ Schedule Information +

+
+
+ + Cron Expression: + + {rule.cron} + +
+
+ Human Readable: {formatCronExpression(rule.cron)} +
+
+ Next Run: {formatDate(rule.nextRunAt)} +
+ {rule.lastProcessedAt && ( +
+ Last Processed: {formatDate(rule.lastProcessedAt)} +
+ )} +
+
+ + {/* Messages */} +
+

+ Messages +

+
+ {rule.input.messages.map((message, index) => ( +
+
+ + {message.role.charAt(0).toUpperCase() + message.role.slice(1)} + +
+
+ {message.content} +
+
+ ))} +
+
+ + {/* Metadata */} +
+

+ Metadata +

+
+
Created: {formatDate(rule.createdAt)}
+ {rule.updatedAt && ( +
Last Updated: {formatDate(rule.updatedAt)}
+ )} +
Rule ID: {rule.id}
+
+
+ + {/* Jobs Created by This Rule */} +
+

+ Jobs Created by This Rule +

+ +
+ + )}
diff --git a/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/create-scheduled-job-rule-form.tsx b/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/create-scheduled-job-rule-form.tsx index 193da0f6..ceb24d5b 100644 --- a/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/create-scheduled-job-rule-form.tsx +++ b/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/create-scheduled-job-rule-form.tsx @@ -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([ - { 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(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; + 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[] => { + 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(normaliseMessages(initialMessages)); + const [scheduledDateTime, setScheduledDateTime] = useState(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 ( - {hasExistingTriggers && onBack ? ( - - ) : hasExistingTriggers ? ( - - - + ) : ( + + + + ) ) : null}
- CREATE SCHEDULED JOB RULE + {title}
-

- Note: Triggers run only on the published version of your workflow. Publish any changes to make them active. -

+ {description ? ( +

+ {description} +

+ ) : null}
} @@ -142,8 +207,8 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg - {loading ? "Creating..." : "Create Rule"} + {loading ? submittingLabel : submitLabel}
@@ -223,3 +288,111 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg ); } + +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 ( + + ); +} + +export function EditScheduledJobRuleForm({ + projectId, + rule, + onCancel, + onUpdated, +}: { + projectId: string; + rule: z.infer; + onCancel: () => void; + onUpdated?: (rule: z.infer) => 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(() => { + return rule.input.messages + .filter((message): message is Extract, { 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); + } + onCancel(); + }; + + return ( + + ); +} diff --git a/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rule-view.tsx b/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rule-view.tsx index 7b44fb46..2bdc8c2f 100644 --- a/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rule-view.tsx +++ b/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rule-view.tsx @@ -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(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={
- + {editing ? ( + + ) : ( + <> + + + + )}
} > @@ -114,74 +138,85 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; )} {!loading && rule && (
- {/* Rule Metadata */} -
-
-
- Rule ID: - {rule.id} -
-
- Status: - - {getStatusText(rule.status, rule.processedAt || null)} - -
-
- Next Run: - - {formatDateTime(rule.nextRunAt)} - -
-
- Created: - - {formatDateTime(rule.createdAt)} - -
- {rule.processedAt && ( -
- Processed: - - {formatDateTime(rule.processedAt)} - + {editing ? ( + setEditing(false)} + onUpdated={(updatedRule) => setRule(updatedRule)} + /> + ) : ( + <> + {/* Rule Metadata */} +
+
+
+ Rule ID: + {rule.id}
- )} - {rule.output?.jobId && ( -
- Job ID: - - - {rule.output.jobId} - - +
+ Status: + + {getStatusText(rule.status, rule.processedAt || null)} + +
+
+ Next Run: + + {formatDateTime(rule.nextRunAt)} + +
+
+ Created: + + {formatDateTime(rule.createdAt)} + +
+ {rule.processedAt && ( +
+ Processed: + + {formatDateTime(rule.processedAt)} + +
+ )} + {rule.output?.jobId && ( +
+ Job ID: + + + {rule.output.jobId} + + +
+ )} + {rule.workerId && ( +
+ Worker ID: + {rule.workerId} +
+ )}
- )} - {rule.workerId && ( -
- Worker ID: - {rule.workerId} -
- )} -
-
+
- {/* Messages */} -
-

- Messages -

-
- {rule.input.messages.map((message, index) => ( -
- + {/* Messages */} +
+

+ Messages +

+
+ {rule.input.messages.map((message, index) => ( +
+ +
+ ))}
- ))} -
-
+
+ + )}
)}
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx index 9d7a4c8b..89308ba5 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx @@ -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; initialDataSources: z.infer[]; + initialTriggers: z.infer[]; eligibleModels: z.infer | "*"; useRag: boolean; useRagUploads: boolean; @@ -44,6 +51,7 @@ export function App({ }); const [project, setProject] = useState>(initialProjectData); const [dataSources, setDataSources] = useState[]>(initialDataSources); + const [triggers, setTriggers] = useState[]>(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} />} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioTriggerTypesPanel.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioTriggerTypesPanel.tsx index 41426bf5..fec1a1f3 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioTriggerTypesPanel.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioTriggerTypesPanel.tsx @@ -13,6 +13,7 @@ interface ComposioTriggerTypesPanelProps { toolkit: z.infer; onBack: () => void; onSelectTriggerType: (triggerType: z.infer) => void; + initialTriggerTypeSlug?: string | null; } type TriggerType = z.infer; @@ -21,6 +22,7 @@ export function ComposioTriggerTypesPanel({ toolkit, onBack, onSelectTriggerType, + initialTriggerTypeSlug, }: ComposioTriggerTypesPanelProps) { const [triggerTypes, setTriggerTypes] = useState([]); const [loading, setLoading] = useState(true); @@ -28,6 +30,7 @@ export function ComposioTriggerTypesPanel({ const [cursor, setCursor] = useState(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 (
@@ -215,4 +230,4 @@ export function ComposioTriggerTypesPanel({ )}
); -} \ No newline at end of file +} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TriggerConfigForm.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggerConfigForm.tsx index 29a32528..6e8267c8 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/TriggerConfigForm.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggerConfigForm.tsx @@ -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) => void; isSubmitting?: boolean; + initialConfig?: Record; } interface JsonSchemaProperty { @@ -36,13 +37,36 @@ export function TriggerConfigForm({ onBack, onSubmit, isSubmitting = false, + initialConfig, }: TriggerConfigFormProps) { - const [formData, setFormData] = useState>({}); + const [formData, setFormData] = useState>(() => { + if (!initialConfig) { + return {}; + } + return Object.entries(initialConfig).reduce>((acc, [key, value]) => { + if (value !== undefined && value !== null) { + acc[key] = String(value); + } + return acc; + }, {}); + }); const [errors, setErrors] = useState>({}); // Parse the JSON schema from triggerType.config const schema = triggerType.config as JsonSchema; + useEffect(() => { + if (!initialConfig) { + return; + } + setFormData(Object.entries(initialConfig).reduce>((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 = {}; @@ -267,4 +291,4 @@ export function TriggerConfigForm({
); -} \ No newline at end of file +} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/page.tsx b/apps/rowboat/app/projects/[projectId]/workflow/page.tsx index 864d31c4..d855c21b 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/page.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/page.tsx @@ -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('fetchProjectController'); const listDataSourcesController = container.resolve('listDataSourcesController'); +const listScheduledJobRulesController = container.resolve('listScheduledJobRulesController'); +const listRecurringJobRulesController = container.resolve('listRecurringJobRulesController'); +const listComposioTriggerDeploymentsController = container.resolve('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 | "*" = '*'; 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 ( ; + +interface TransformParams { + scheduled: Array<{ + id: string; + nextRunAt: string; + status: 'pending' | 'processing' | 'triggered'; + input?: { messages: Array> }; + }>; + recurring: Array<{ + id: string; + cron: string; + nextRunAt: string | null; + disabled: boolean; + input?: { messages: Array> }; + }>; + composio: Array<{ + id: string; + triggerTypeName: string; + toolkitSlug: string; + triggerTypeSlug: string; + triggerConfig: Record; + }>; +} + +export function transformTriggersForCopilot({ + scheduled, + recurring, + composio, +}: TransformParams): CopilotTrigger[] { + const placeholderInput = { + messages: [ + { + role: "user" as const, + content: "Trigger execution", + }, + ], + } satisfies { messages: Array> }; + + 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[]; +} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index 476d8366..6ef91e45 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx @@ -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[]; + triggers: z.infer[]; workflow: z.infer; useRag: boolean; useRagUploads: boolean; @@ -998,6 +1002,7 @@ export function WorkflowEditor({ onProjectToolsUpdated?: () => void; onDataSourcesUpdated?: () => void; onProjectConfigUpdated?: () => void; + onTriggersUpdated?: () => Promise | 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' && ( diff --git a/apps/rowboat/components/common/compose-box-copilot.tsx b/apps/rowboat/components/common/compose-box-copilot.tsx index d2ddd4d2..22c12503 100644 --- a/apps/rowboat/components/common/compose-box-copilot.tsx +++ b/apps/rowboat/components/common/compose-box-copilot.tsx @@ -268,7 +268,7 @@ function CopilotStatusBar({ // Show real button when ready return (