From 15bc85145fd0fa92af2781626f49c13c26d6d531 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Tue, 7 Oct 2025 14:49:28 +0800 Subject: [PATCH] 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. --- .../copilot/components/messages.tsx | 479 ++---------------- .../copilot/components/use-trigger-actions.ts | 465 +++++++++++++++++ 2 files changed, 512 insertions(+), 432 deletions(-) create mode 100644 apps/rowboat/app/projects/[projectId]/copilot/components/use-trigger-actions.ts diff --git a/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx b/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx index 6a699f37..e152a7cd 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx @@ -8,41 +8,13 @@ import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, Ch 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 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 CopilotTriggerType = z.infer; -let scheduledJobActionsPromise: Promise | null = null; -let recurringJobActionsPromise: Promise | null = null; -let composioActionsPromise: Promise | null = null; - -function loadScheduledJobActions(): Promise { - if (!scheduledJobActionsPromise) { - scheduledJobActionsPromise = import('@/app/actions/scheduled-job-rules.actions'); - } - return scheduledJobActionsPromise; -} - -function loadRecurringJobActions(): Promise { - if (!recurringJobActionsPromise) { - recurringJobActionsPromise = import('@/app/actions/recurring-job-rules.actions'); - } - return recurringJobActionsPromise; -} - -function loadComposioActions(): Promise { - if (!composioActionsPromise) { - composioActionsPromise = import('@/app/actions/composio.actions'); - } - return composioActionsPromise; -} - const CopilotResponsePart = z.union([ z.object({ type: z.literal('text'), @@ -232,104 +204,6 @@ function AssistantMessage({ }) { const blocks = useParsedBlocks(content); const [appliedActions, setAppliedActions] = useState>(new Set()); - // Remove autoApplyEnabled and useEffect for auto-apply - - const triggersRef = useRef(triggers); - const pendingTriggerEditsRef = useRef>(new Map()); - const triggerUpdateCallbackRef = useRef(onTriggersUpdated); - const [triggerSetupModal, setTriggerSetupModal] = useState<{ - action: z.infer['content']; - actionIndex: number; - messageIndex: number; - initialToolkitSlug: string | null; - initialTriggerTypeSlug: string | null; - initialConfig?: Record; - } | null>(null); - - useEffect(() => { - triggersRef.current = triggers; - pendingTriggerEditsRef.current.clear(); - }, [triggers]); - - useEffect(() => { - triggerUpdateCallbackRef.current = onTriggersUpdated; - }, [onTriggersUpdated]); - - const refreshTriggers = useCallback(async () => { - const callback = triggerUpdateCallbackRef.current; - if (!callback) { - return; - } - try { - await callback(); - } catch (error) { - console.error('Failed to refresh triggers after Copilot action', error); - } - }, []); - - const requestTriggerSetup = useCallback((params: { - action: z.infer['content']; - actionIndex: number; - messageIndex: number; - }) => { - const { action, actionIndex, messageIndex: msgIndex } = params; - const changes = (action?.config_changes ?? {}) as Record; - const toStringOrNull = (value: unknown): string | null => { - if (typeof value === 'string' && value.trim().length > 0) { - return value; - } - return null; - }; - const deriveSlug = (primary: unknown, secondary: unknown, tertiary: unknown): string | null => { - return toStringOrNull(primary) ?? toStringOrNull(secondary) ?? toStringOrNull(tertiary); - }; - const toolkitSlug = deriveSlug( - changes.toolkitSlug, - changes.toolkit_slug, - typeof changes.toolkit === 'object' && changes.toolkit !== null ? (changes.toolkit as any).slug : changes.toolkit - ); - const triggerTypeSlug = deriveSlug( - changes.triggerTypeSlug, - changes.trigger_type_slug, - typeof changes.triggerType === 'object' && changes.triggerType !== null ? (changes.triggerType as any).slug : changes.triggerType - ); - const triggerConfigCandidate = (changes.triggerConfig ?? changes.trigger_config ?? changes.config) as unknown; - const triggerConfig = typeof triggerConfigCandidate === 'object' && triggerConfigCandidate !== null - ? (triggerConfigCandidate as Record) - : undefined; - - setTriggerSetupModal(prev => { - if (prev && prev.actionIndex === actionIndex && prev.messageIndex === msgIndex) { - return prev; - } - return { - action, - actionIndex, - messageIndex: msgIndex, - initialToolkitSlug: toolkitSlug, - initialTriggerTypeSlug: triggerTypeSlug, - initialConfig: triggerConfig, - }; - }); - }, []); - - const handleTriggerSetupCreated = useCallback(async () => { - if (!triggerSetupModal) { - return; - } - const index = triggerSetupModal.actionIndex; - setAppliedActions(prev => { - const next = new Set(prev); - next.add(index); - return next; - }); - await refreshTriggers(); - setTriggerSetupModal(null); - }, [refreshTriggers, triggerSetupModal]); - - const handleTriggerSetupClosed = useCallback(() => { - setTriggerSetupModal(null); - }, []); // parse actions from parts const parsed = useMemo(() => { @@ -347,6 +221,46 @@ function AssistantMessage({ return result; }, [blocks]); + const hasUpcomingReplacement = useCallback((candidate: z.infer['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; @@ -477,283 +391,6 @@ function AssistantMessage({ return false; }, [dispatch, workflow.agents, workflow.tools]); - const handleTriggerAction = useCallback(async (action: any, actionIndex?: number): Promise => { - const configType = action.config_type; - const actionType = action.action; - const triggerList = triggersRef.current ?? []; - const key = `${configType}:${action.name}`; - - const hasUpcomingReplacement = () => parsed.some((part, idx) => - idx > (actionIndex ?? -1) && - part.type === 'action' && - part.action.config_type === configType && - part.action.name === action.name && - part.action.action === 'create_new' - ); - - try { - if (configType === 'one_time_trigger') { - if (actionType === 'create_new') { - const pending = pendingTriggerEditsRef.current.get(key); - - if (pending && pending.type === 'one_time') { - const scheduledTime = action.config_changes?.scheduledTime ?? pending.nextRunAt; - const input = action.config_changes?.input ?? pending.input; - - if (!scheduledTime || !input) { - console.error('Missing data for one-time trigger update via replacement', action); - return false; - } - - const { updateScheduledJobRule } = await loadScheduledJobActions(); - await updateScheduledJobRule({ - projectId, - ruleId: pending.id, - scheduledTime, - input, - }); - - pendingTriggerEditsRef.current.delete(key); - return true; - } - - const { scheduledTime, input } = action.config_changes || {}; - if (!scheduledTime || !input) { - console.error('Missing scheduledTime or input for one-time trigger', action); - return false; - } - const { createScheduledJobRule } = await loadScheduledJobActions(); - await createScheduledJobRule({ - projectId, - scheduledTime, - input, - }); - return true; - } - - const target = triggerList.find( - (trigger): trigger is Extract, { 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 (actionType === 'delete') { - if (hasUpcomingReplacement()) { - pendingTriggerEditsRef.current.set(key, target); - return true; - } - - pendingTriggerEditsRef.current.delete(key); - - await deleteScheduledJobRule({ projectId, ruleId: target.id }); - return true; - } - - if (actionType === '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 = action.config_changes?.scheduledTime ?? existing.nextRunAt; - const input = action.config_changes?.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, - }); - - return true; - } - } - - if (configType === 'recurring_trigger') { - if (actionType === 'create_new') { - const pending = pendingTriggerEditsRef.current.get(key); - - const { - createRecurringJobRule, - updateRecurringJobRule, - toggleRecurringJobRule, - } = await loadRecurringJobActions(); - - if (pending && pending.type === 'recurring') { - const cron = action.config_changes?.cron ?? pending.cron; - const input = action.config_changes?.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, - }); - - const hasDisabledToggle = Object.prototype.hasOwnProperty.call(action.config_changes ?? {}, 'disabled'); - if (hasDisabledToggle) { - const desiredDisabled = typeof action.config_changes?.disabled === 'boolean' - ? action.config_changes.disabled - : pending.disabled; - if (typeof desiredDisabled === 'boolean' && desiredDisabled !== pending.disabled) { - await toggleRecurringJobRule({ ruleId: pending.id, disabled: desiredDisabled }); - } - } - - pendingTriggerEditsRef.current.delete(key); - return Boolean(updatedRule?.id); - } - - const { cron, input } = action.config_changes || {}; - if (!cron || !input) { - console.error('Missing cron or input for recurring trigger', action); - return false; - } - - await createRecurringJobRule({ - projectId, - cron, - input, - }); - return true; - } - - const target = triggerList.find( - (trigger): trigger is Extract, { type: 'recurring' }> => - trigger.type === 'recurring' && trigger.name === action.name - ); - - if (!target) { - console.warn('Unable to resolve recurring trigger for action', action.name); - return false; - } - - const { - fetchRecurringJobRule, - deleteRecurringJobRule, - toggleRecurringJobRule, - updateRecurringJobRule, - } = await loadRecurringJobActions(); - - if (actionType === 'delete') { - if (hasUpcomingReplacement()) { - pendingTriggerEditsRef.current.set(key, target); - return true; - } - - pendingTriggerEditsRef.current.delete(key); - - await deleteRecurringJobRule({ projectId, ruleId: target.id }); - return true; - } - - if (actionType === '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 action.config_changes?.disabled === 'boolean' - ? action.config_changes.disabled - : existing.disabled; - - const hasCronChange = Object.prototype.hasOwnProperty.call(action.config_changes ?? {}, 'cron'); - const hasInputChange = Object.prototype.hasOwnProperty.call(action.config_changes ?? {}, 'input'); - const hasDisabledToggle = Object.prototype.hasOwnProperty.call(action.config_changes ?? {}, 'disabled'); - - if (!hasCronChange && !hasInputChange && hasDisabledToggle) { - if (desiredDisabled !== existing.disabled) { - await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled }); - } - return true; - } - - const cron = action.config_changes?.cron ?? existing.cron; - const input = action.config_changes?.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 }); - } - - return true; - } - } - - if (configType === 'external_trigger') { - if (actionType === 'create_new') { - if (typeof actionIndex === 'number') { - requestTriggerSetup({ action, actionIndex, messageIndex }); - } - return false; - } - } - - if ((configType === 'external_trigger' || configType === 'external') && actionType === 'delete') { - const target = triggerList.find((trigger): trigger is Extract => { - 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; - } - } catch (error) { - console.error('Failed to handle trigger action', action, error); - return false; - } - - console.warn('Unhandled trigger action from Copilot applyAction', action); - return false; - }, [projectId, parsed, requestTriggerSetup, messageIndex]); - // Memoized handleApplyAll for useEffect dependencies const handleApplyAll = useCallback(async () => { const unapplied = parsed.reduce['content']; actionIndex: number }>>((acc, part, idx) => { @@ -764,20 +401,16 @@ function AssistantMessage({ }, []); const newlyApplied: number[] = []; - let triggerMutated = false; 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) + ? await handleTriggerAction(action, { actionIndex, messageIndex }) : applyAction(action); if (success) { newlyApplied.push(actionIndex); - if (isTrigger) { - triggerMutated = true; - } } } catch (error) { console.error('Failed to apply Copilot action', action, error); @@ -791,11 +424,7 @@ function AssistantMessage({ return next; }); } - - if (triggerMutated) { - await refreshTriggers(); - } - }, [parsed, appliedActions, applyAction, handleTriggerAction, refreshTriggers]); + }, [parsed, appliedActions, applyAction, handleTriggerAction, messageIndex]); // Manual single apply (from card) const handleSingleApply = useCallback(async (action: z.infer['content'], actionIndex: number) => { @@ -806,30 +435,23 @@ function AssistantMessage({ 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) + ? await handleTriggerAction(action, { actionIndex, messageIndex }) : applyAction(action); if (success) { setAppliedActions(prev => new Set([...prev, actionIndex])); - if (isTrigger) { - await refreshTriggers(); - } } } catch (error) { console.error('Failed to apply Copilot action', action, error); } - }, [appliedActions, applyAction, handleTriggerAction, refreshTriggers]); + }, [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 = ''; @@ -846,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(', '); } @@ -869,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(null); - // Only call onStatusBarChange if the serializable status actually changes const lastStatusRef = useRef(null); useEffect(() => { @@ -1028,9 +647,6 @@ export function Messages({ return () => clearTimeout(timeoutId); }, [messages, loadingResponse]); - // Track the latest status bar info - const latestStatusBar = useRef(null); - const renderMessage = (message: z.infer, messageIndex: number) => { if (message.role === 'assistant') { return ( @@ -1047,7 +663,6 @@ export function Messages({ onStatusBarChange={status => { // Only update for the last assistant message if (messageIndex === displayMessages.length - 1) { - latestStatusBar.current = status; onStatusBarChange?.(status); } }} diff --git a/apps/rowboat/app/projects/[projectId]/copilot/components/use-trigger-actions.ts b/apps/rowboat/app/projects/[projectId]/copilot/components/use-trigger-actions.ts new file mode 100644 index 00000000..80c37e47 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/copilot/components/use-trigger-actions.ts @@ -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; +type CopilotAction = z.infer['content']; + +export interface TriggerSetupModalState { + action: CopilotAction; + actionIndex: number; + messageIndex: number; + initialToolkitSlug: string | null; + initialTriggerTypeSlug: string | null; + initialConfig?: Record; +} + +interface UseCopilotTriggerActionsParams { + projectId: string; + triggers?: CopilotTrigger[]; + onTriggersUpdated?: () => Promise | 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; + handleTriggerAction: (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => Promise; +} + +let scheduledJobActionsPromise: Promise | null = null; +let recurringJobActionsPromise: Promise | null = null; +let composioActionsPromise: Promise | null = null; + +function loadScheduledJobActions(): Promise { + if (!scheduledJobActionsPromise) { + scheduledJobActionsPromise = import('@/app/actions/scheduled-job-rules.actions'); + } + return scheduledJobActionsPromise; +} + +function loadRecurringJobActions(): Promise { + if (!recurringJobActionsPromise) { + recurringJobActionsPromise = import('@/app/actions/recurring-job-rules.actions'); + } + return recurringJobActionsPromise; +} + +function loadComposioActions(): Promise { + if (!composioActionsPromise) { + composioActionsPromise = import('@/app/actions/composio.actions'); + } + return composioActionsPromise; +} + +const hasOwn = (obj: Record | 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; + +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; + + 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) + : undefined; + + return { + action, + actionIndex, + messageIndex, + initialToolkitSlug, + initialTriggerTypeSlug, + initialConfig, + }; +}; + +export function useCopilotTriggerActions({ + projectId, + triggers, + onTriggersUpdated, + hasUpcomingReplacement, +}: UseCopilotTriggerActionsParams): UseCopilotTriggerActionsResult { + const [triggerSetupModal, setTriggerSetupModal] = useState(null); + const triggersRef = useRef(triggers ?? []); + const pendingTriggerEditsRef = useRef>(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; + 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 => + 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; + 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 => + 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 => { + 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, + }; +}