mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
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.
This commit is contained in:
parent
c01fbb09f4
commit
15bc85145f
2 changed files with 512 additions and 432 deletions
|
|
@ -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<typeof TriggerSchemaForCopilot>;
|
||||
|
||||
let scheduledJobActionsPromise: Promise<ScheduledJobActionsModule> | null = null;
|
||||
let recurringJobActionsPromise: Promise<RecurringJobActionsModule> | null = null;
|
||||
let composioActionsPromise: Promise<ComposioActionsModule> | null = null;
|
||||
|
||||
function loadScheduledJobActions(): Promise<ScheduledJobActionsModule> {
|
||||
if (!scheduledJobActionsPromise) {
|
||||
scheduledJobActionsPromise = import('@/app/actions/scheduled-job-rules.actions');
|
||||
}
|
||||
return scheduledJobActionsPromise;
|
||||
}
|
||||
|
||||
function loadRecurringJobActions(): Promise<RecurringJobActionsModule> {
|
||||
if (!recurringJobActionsPromise) {
|
||||
recurringJobActionsPromise = import('@/app/actions/recurring-job-rules.actions');
|
||||
}
|
||||
return recurringJobActionsPromise;
|
||||
}
|
||||
|
||||
function loadComposioActions(): Promise<ComposioActionsModule> {
|
||||
if (!composioActionsPromise) {
|
||||
composioActionsPromise = import('@/app/actions/composio.actions');
|
||||
}
|
||||
return composioActionsPromise;
|
||||
}
|
||||
|
||||
const CopilotResponsePart = z.union([
|
||||
z.object({
|
||||
type: z.literal('text'),
|
||||
|
|
@ -232,104 +204,6 @@ function AssistantMessage({
|
|||
}) {
|
||||
const blocks = useParsedBlocks(content);
|
||||
const [appliedActions, setAppliedActions] = useState<Set<number>>(new Set());
|
||||
// Remove autoApplyEnabled and useEffect for auto-apply
|
||||
|
||||
const triggersRef = useRef<CopilotTriggerType[] | undefined>(triggers);
|
||||
const pendingTriggerEditsRef = useRef<Map<string, CopilotTriggerType>>(new Map());
|
||||
const triggerUpdateCallbackRef = useRef<typeof onTriggersUpdated>(onTriggersUpdated);
|
||||
const [triggerSetupModal, setTriggerSetupModal] = useState<{
|
||||
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'];
|
||||
actionIndex: number;
|
||||
messageIndex: number;
|
||||
initialToolkitSlug: string | null;
|
||||
initialTriggerTypeSlug: string | null;
|
||||
initialConfig?: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
triggersRef.current = triggers;
|
||||
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<typeof CopilotAssistantMessageActionPart>['content'];
|
||||
actionIndex: number;
|
||||
messageIndex: number;
|
||||
}) => {
|
||||
const { action, actionIndex, messageIndex: msgIndex } = params;
|
||||
const changes = (action?.config_changes ?? {}) as Record<string, unknown>;
|
||||
const toStringOrNull = (value: unknown): string | null => {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const deriveSlug = (primary: unknown, secondary: unknown, tertiary: unknown): string | null => {
|
||||
return toStringOrNull(primary) ?? toStringOrNull(secondary) ?? toStringOrNull(tertiary);
|
||||
};
|
||||
const toolkitSlug = deriveSlug(
|
||||
changes.toolkitSlug,
|
||||
changes.toolkit_slug,
|
||||
typeof changes.toolkit === 'object' && changes.toolkit !== null ? (changes.toolkit as any).slug : changes.toolkit
|
||||
);
|
||||
const triggerTypeSlug = deriveSlug(
|
||||
changes.triggerTypeSlug,
|
||||
changes.trigger_type_slug,
|
||||
typeof changes.triggerType === 'object' && changes.triggerType !== null ? (changes.triggerType as any).slug : changes.triggerType
|
||||
);
|
||||
const triggerConfigCandidate = (changes.triggerConfig ?? changes.trigger_config ?? changes.config) as unknown;
|
||||
const triggerConfig = typeof triggerConfigCandidate === 'object' && triggerConfigCandidate !== null
|
||||
? (triggerConfigCandidate as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
setTriggerSetupModal(prev => {
|
||||
if (prev && prev.actionIndex === actionIndex && prev.messageIndex === msgIndex) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
action,
|
||||
actionIndex,
|
||||
messageIndex: msgIndex,
|
||||
initialToolkitSlug: toolkitSlug,
|
||||
initialTriggerTypeSlug: triggerTypeSlug,
|
||||
initialConfig: triggerConfig,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTriggerSetupCreated = useCallback(async () => {
|
||||
if (!triggerSetupModal) {
|
||||
return;
|
||||
}
|
||||
const index = triggerSetupModal.actionIndex;
|
||||
setAppliedActions(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
await refreshTriggers();
|
||||
setTriggerSetupModal(null);
|
||||
}, [refreshTriggers, triggerSetupModal]);
|
||||
|
||||
const handleTriggerSetupClosed = useCallback(() => {
|
||||
setTriggerSetupModal(null);
|
||||
}, []);
|
||||
|
||||
// parse actions from parts
|
||||
const parsed = useMemo(() => {
|
||||
|
|
@ -347,6 +221,46 @@ function AssistantMessage({
|
|||
return result;
|
||||
}, [blocks]);
|
||||
|
||||
const hasUpcomingReplacement = useCallback((candidate: z.infer<typeof CopilotAssistantMessageActionPart>['content'], currentIndex: number = -1) => {
|
||||
return parsed.some((part, idx) =>
|
||||
idx > currentIndex &&
|
||||
part.type === 'action' &&
|
||||
part.action.config_type === candidate.config_type &&
|
||||
part.action.name === candidate.name &&
|
||||
part.action.action === 'create_new'
|
||||
);
|
||||
}, [parsed]);
|
||||
|
||||
const {
|
||||
triggerSetupModal,
|
||||
requestTriggerSetup,
|
||||
closeTriggerSetup,
|
||||
handleTriggerCreatedViaModal,
|
||||
handleTriggerAction,
|
||||
} = useCopilotTriggerActions({
|
||||
projectId,
|
||||
triggers,
|
||||
onTriggersUpdated,
|
||||
hasUpcomingReplacement,
|
||||
});
|
||||
|
||||
const handleTriggerSetupCreated = useCallback(async () => {
|
||||
if (!triggerSetupModal) {
|
||||
return;
|
||||
}
|
||||
const index = triggerSetupModal.actionIndex;
|
||||
setAppliedActions(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
await handleTriggerCreatedViaModal();
|
||||
}, [handleTriggerCreatedViaModal, triggerSetupModal]);
|
||||
|
||||
const handleTriggerSetupClosed = useCallback(() => {
|
||||
closeTriggerSetup();
|
||||
}, [closeTriggerSetup]);
|
||||
|
||||
// Count action cards for tracking
|
||||
const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action');
|
||||
const totalActions = parsed.filter(part => part.type === 'action').length;
|
||||
|
|
@ -477,283 +391,6 @@ function AssistantMessage({
|
|||
return false;
|
||||
}, [dispatch, workflow.agents, workflow.tools]);
|
||||
|
||||
const handleTriggerAction = useCallback(async (action: any, actionIndex?: number): Promise<boolean> => {
|
||||
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<z.infer<typeof TriggerSchemaForCopilot>, { 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<z.infer<typeof TriggerSchemaForCopilot>, { 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<CopilotTriggerType, { type: 'external' }> => {
|
||||
if (trigger.type !== 'external') {
|
||||
return false;
|
||||
}
|
||||
const maybeName = (trigger as unknown as { name?: string }).name;
|
||||
return (
|
||||
trigger.triggerTypeName === action.name ||
|
||||
trigger.triggerTypeSlug === action.name ||
|
||||
trigger.id === action.name ||
|
||||
maybeName === action.name
|
||||
);
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
console.warn('Unable to resolve external trigger for action', action.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { deleteComposioTriggerDeployment } = await loadComposioActions();
|
||||
await deleteComposioTriggerDeployment({ projectId, deploymentId: target.id });
|
||||
return true;
|
||||
}
|
||||
} 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<Array<{ action: z.infer<typeof CopilotAssistantMessageActionPart>['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<typeof CopilotAssistantMessageActionPart>['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<any>(null);
|
||||
|
||||
// Only call onStatusBarChange if the serializable status actually changes
|
||||
const lastStatusRef = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
|
|
@ -1028,9 +647,6 @@ export function Messages({
|
|||
return () => clearTimeout(timeoutId);
|
||||
}, [messages, loadingResponse]);
|
||||
|
||||
// Track the latest status bar info
|
||||
const latestStatusBar = useRef<any>(null);
|
||||
|
||||
const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => {
|
||||
if (message.role === 'assistant') {
|
||||
return (
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,465 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
|
||||
import { Message } from "@/app/lib/types/types";
|
||||
|
||||
type ScheduledJobActionsModule = typeof import('@/app/actions/scheduled-job-rules.actions');
|
||||
type RecurringJobActionsModule = typeof import('@/app/actions/recurring-job-rules.actions');
|
||||
type ComposioActionsModule = typeof import('@/app/actions/composio.actions');
|
||||
|
||||
type CopilotTrigger = z.infer<typeof TriggerSchemaForCopilot>;
|
||||
type CopilotAction = z.infer<typeof CopilotAssistantMessageActionPart>['content'];
|
||||
|
||||
export interface TriggerSetupModalState {
|
||||
action: CopilotAction;
|
||||
actionIndex: number;
|
||||
messageIndex: number;
|
||||
initialToolkitSlug: string | null;
|
||||
initialTriggerTypeSlug: string | null;
|
||||
initialConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface UseCopilotTriggerActionsParams {
|
||||
projectId: string;
|
||||
triggers?: CopilotTrigger[];
|
||||
onTriggersUpdated?: () => Promise<void> | void;
|
||||
hasUpcomingReplacement: (action: CopilotAction, currentIndex?: number) => boolean;
|
||||
}
|
||||
|
||||
interface UseCopilotTriggerActionsResult {
|
||||
triggerSetupModal: TriggerSetupModalState | null;
|
||||
requestTriggerSetup: (params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => void;
|
||||
closeTriggerSetup: () => void;
|
||||
handleTriggerCreatedViaModal: () => Promise<void>;
|
||||
handleTriggerAction: (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
let scheduledJobActionsPromise: Promise<ScheduledJobActionsModule> | null = null;
|
||||
let recurringJobActionsPromise: Promise<RecurringJobActionsModule> | null = null;
|
||||
let composioActionsPromise: Promise<ComposioActionsModule> | null = null;
|
||||
|
||||
function loadScheduledJobActions(): Promise<ScheduledJobActionsModule> {
|
||||
if (!scheduledJobActionsPromise) {
|
||||
scheduledJobActionsPromise = import('@/app/actions/scheduled-job-rules.actions');
|
||||
}
|
||||
return scheduledJobActionsPromise;
|
||||
}
|
||||
|
||||
function loadRecurringJobActions(): Promise<RecurringJobActionsModule> {
|
||||
if (!recurringJobActionsPromise) {
|
||||
recurringJobActionsPromise = import('@/app/actions/recurring-job-rules.actions');
|
||||
}
|
||||
return recurringJobActionsPromise;
|
||||
}
|
||||
|
||||
function loadComposioActions(): Promise<ComposioActionsModule> {
|
||||
if (!composioActionsPromise) {
|
||||
composioActionsPromise = import('@/app/actions/composio.actions');
|
||||
}
|
||||
return composioActionsPromise;
|
||||
}
|
||||
|
||||
const hasOwn = (obj: Record<string, unknown> | undefined, key: string) =>
|
||||
!!obj && Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
const buildTriggerKey = (configType: string, name: string) => `${configType}:${name}`;
|
||||
|
||||
const toStringOrNull = (value: unknown): string | null => {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractSlug = (primary: unknown, secondary: unknown, tertiary: unknown): string | null => {
|
||||
return (
|
||||
toStringOrNull(primary) ??
|
||||
toStringOrNull(secondary) ??
|
||||
(typeof tertiary === 'object' && tertiary !== null ? toStringOrNull((tertiary as { slug?: unknown }).slug) : toStringOrNull(tertiary))
|
||||
);
|
||||
};
|
||||
|
||||
const TriggerInputSchema = z.object({
|
||||
messages: z.array(Message),
|
||||
});
|
||||
|
||||
type TriggerInput = z.infer<typeof TriggerInputSchema>;
|
||||
|
||||
const coerceTriggerInput = (value: unknown, fallback?: TriggerInput | null): TriggerInput | null => {
|
||||
if (value) {
|
||||
const parsed = TriggerInputSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
}
|
||||
return fallback ?? null;
|
||||
};
|
||||
|
||||
const extractTriggerSetupState = (
|
||||
params: { action: CopilotAction; actionIndex: number; messageIndex: number }
|
||||
): TriggerSetupModalState => {
|
||||
const { action, actionIndex, messageIndex } = params;
|
||||
const changes = (action?.config_changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const initialToolkitSlug = extractSlug(changes.toolkitSlug, changes.toolkit_slug, changes.toolkit);
|
||||
const initialTriggerTypeSlug = extractSlug(changes.triggerTypeSlug, changes.trigger_type_slug, changes.triggerType);
|
||||
const triggerConfigCandidate = (changes.triggerConfig ?? changes.trigger_config ?? changes.config) as unknown;
|
||||
const initialConfig = typeof triggerConfigCandidate === 'object' && triggerConfigCandidate !== null
|
||||
? (triggerConfigCandidate as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
action,
|
||||
actionIndex,
|
||||
messageIndex,
|
||||
initialToolkitSlug,
|
||||
initialTriggerTypeSlug,
|
||||
initialConfig,
|
||||
};
|
||||
};
|
||||
|
||||
export function useCopilotTriggerActions({
|
||||
projectId,
|
||||
triggers,
|
||||
onTriggersUpdated,
|
||||
hasUpcomingReplacement,
|
||||
}: UseCopilotTriggerActionsParams): UseCopilotTriggerActionsResult {
|
||||
const [triggerSetupModal, setTriggerSetupModal] = useState<TriggerSetupModalState | null>(null);
|
||||
const triggersRef = useRef<CopilotTrigger[]>(triggers ?? []);
|
||||
const pendingTriggerEditsRef = useRef<Map<string, CopilotTrigger>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
triggersRef.current = triggers ?? [];
|
||||
pendingTriggerEditsRef.current.clear();
|
||||
}, [triggers]);
|
||||
|
||||
const refreshTriggers = useCallback(async () => {
|
||||
if (!onTriggersUpdated) {
|
||||
return;
|
||||
}
|
||||
await onTriggersUpdated();
|
||||
}, [onTriggersUpdated]);
|
||||
|
||||
const requestTriggerSetup = useCallback((params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => {
|
||||
setTriggerSetupModal(prev => {
|
||||
if (prev && prev.actionIndex === params.actionIndex && prev.messageIndex === params.messageIndex) {
|
||||
return prev;
|
||||
}
|
||||
return extractTriggerSetupState(params);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeTriggerSetup = useCallback(() => {
|
||||
setTriggerSetupModal(null);
|
||||
}, []);
|
||||
|
||||
const handleTriggerCreatedViaModal = useCallback(async () => {
|
||||
await refreshTriggers();
|
||||
closeTriggerSetup();
|
||||
}, [refreshTriggers, closeTriggerSetup]);
|
||||
|
||||
const handleOneTimeTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
|
||||
const triggerList = triggersRef.current;
|
||||
const key = buildTriggerKey(action.config_type, action.name);
|
||||
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
|
||||
let mutated = false;
|
||||
const actionIndex = context?.actionIndex;
|
||||
|
||||
if (action.action === 'create_new') {
|
||||
const pending = pendingTriggerEditsRef.current.get(key);
|
||||
const { createScheduledJobRule, updateScheduledJobRule } = await loadScheduledJobActions();
|
||||
|
||||
if (pending && pending.type === 'one_time') {
|
||||
const scheduledTime = (actionChanges.scheduledTime as string) ?? pending.nextRunAt;
|
||||
const input = coerceTriggerInput(actionChanges.input, pending.input);
|
||||
if (!scheduledTime || !input) {
|
||||
console.error('Missing data for one-time trigger update via replacement', action);
|
||||
return false;
|
||||
}
|
||||
|
||||
await updateScheduledJobRule({
|
||||
projectId,
|
||||
ruleId: pending.id,
|
||||
scheduledTime,
|
||||
input,
|
||||
});
|
||||
pendingTriggerEditsRef.current.delete(key);
|
||||
mutated = true;
|
||||
} else {
|
||||
const scheduledTime = actionChanges.scheduledTime as string | undefined;
|
||||
const input = coerceTriggerInput(actionChanges.input);
|
||||
if (!scheduledTime || !input) {
|
||||
console.error('Missing scheduledTime or input for one-time trigger creation', action);
|
||||
return false;
|
||||
}
|
||||
|
||||
await createScheduledJobRule({
|
||||
projectId,
|
||||
scheduledTime,
|
||||
input,
|
||||
});
|
||||
mutated = true;
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
const target = triggerList.find(
|
||||
(trigger): trigger is Extract<CopilotTrigger, { type: 'one_time' }> =>
|
||||
trigger.type === 'one_time' && trigger.name === action.name
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
console.warn('Unable to resolve one-time trigger for action', action.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
fetchScheduledJobRule,
|
||||
deleteScheduledJobRule,
|
||||
updateScheduledJobRule,
|
||||
} = await loadScheduledJobActions();
|
||||
|
||||
if (action.action === 'delete') {
|
||||
if (hasUpcomingReplacement(action, actionIndex)) {
|
||||
pendingTriggerEditsRef.current.set(key, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
pendingTriggerEditsRef.current.delete(key);
|
||||
await deleteScheduledJobRule({ projectId, ruleId: target.id });
|
||||
mutated = true;
|
||||
return mutated;
|
||||
}
|
||||
|
||||
if (action.action === 'edit') {
|
||||
const existing = await fetchScheduledJobRule({ ruleId: target.id });
|
||||
if (!existing) {
|
||||
console.error('Failed to load existing one-time trigger for edit', action.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
const scheduledTime = (actionChanges.scheduledTime as string) ?? existing.nextRunAt;
|
||||
const input = coerceTriggerInput(actionChanges.input, existing.input);
|
||||
|
||||
if (!scheduledTime || !input) {
|
||||
console.error('Missing data for one-time trigger edit', action);
|
||||
return false;
|
||||
}
|
||||
|
||||
await updateScheduledJobRule({
|
||||
projectId,
|
||||
ruleId: target.id,
|
||||
scheduledTime,
|
||||
input,
|
||||
});
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
return mutated;
|
||||
}, [projectId, hasUpcomingReplacement]);
|
||||
|
||||
const handleRecurringTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
|
||||
const triggerList = triggersRef.current;
|
||||
const key = buildTriggerKey(action.config_type, action.name);
|
||||
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
|
||||
let mutated = false;
|
||||
const actionIndex = context?.actionIndex;
|
||||
|
||||
const {
|
||||
createRecurringJobRule,
|
||||
updateRecurringJobRule,
|
||||
toggleRecurringJobRule,
|
||||
deleteRecurringJobRule,
|
||||
fetchRecurringJobRule,
|
||||
} = await loadRecurringJobActions();
|
||||
|
||||
if (action.action === 'create_new') {
|
||||
const pending = pendingTriggerEditsRef.current.get(key);
|
||||
|
||||
if (pending && pending.type === 'recurring') {
|
||||
const cron = (actionChanges.cron as string) ?? pending.cron;
|
||||
const input = coerceTriggerInput(actionChanges.input, pending.input);
|
||||
if (!cron || !input) {
|
||||
console.error('Missing data for recurring trigger update via replacement', action);
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedRule = await updateRecurringJobRule({
|
||||
projectId,
|
||||
ruleId: pending.id,
|
||||
cron,
|
||||
input,
|
||||
});
|
||||
|
||||
if (hasOwn(actionChanges, 'disabled')) {
|
||||
const desired = typeof actionChanges.disabled === 'boolean'
|
||||
? actionChanges.disabled
|
||||
: pending.disabled;
|
||||
if (typeof desired === 'boolean' && desired !== pending.disabled) {
|
||||
await toggleRecurringJobRule({ ruleId: pending.id, disabled: desired });
|
||||
}
|
||||
}
|
||||
|
||||
pendingTriggerEditsRef.current.delete(key);
|
||||
mutated = Boolean(updatedRule?.id);
|
||||
} else {
|
||||
const cron = actionChanges.cron as string | undefined;
|
||||
const input = coerceTriggerInput(actionChanges.input);
|
||||
if (!cron || !input) {
|
||||
console.error('Missing cron or input for recurring trigger creation', action);
|
||||
return false;
|
||||
}
|
||||
|
||||
await createRecurringJobRule({
|
||||
projectId,
|
||||
cron,
|
||||
input,
|
||||
});
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
return mutated;
|
||||
}
|
||||
|
||||
const target = triggerList.find(
|
||||
(trigger): trigger is Extract<CopilotTrigger, { type: 'recurring' }> =>
|
||||
trigger.type === 'recurring' && trigger.name === action.name
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
console.warn('Unable to resolve recurring trigger for action', action.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action.action === 'delete') {
|
||||
if (hasUpcomingReplacement(action, actionIndex)) {
|
||||
pendingTriggerEditsRef.current.set(key, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
pendingTriggerEditsRef.current.delete(key);
|
||||
await deleteRecurringJobRule({ projectId, ruleId: target.id });
|
||||
mutated = true;
|
||||
return mutated;
|
||||
}
|
||||
|
||||
if (action.action === 'edit') {
|
||||
const existing = await fetchRecurringJobRule({ ruleId: target.id });
|
||||
if (!existing) {
|
||||
console.error('Failed to load existing recurring trigger for edit', action.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
const desiredDisabled = typeof actionChanges.disabled === 'boolean'
|
||||
? actionChanges.disabled
|
||||
: existing.disabled;
|
||||
|
||||
const hasCronChange = hasOwn(actionChanges, 'cron');
|
||||
const hasInputChange = hasOwn(actionChanges, 'input');
|
||||
const hasDisabledToggle = hasOwn(actionChanges, 'disabled');
|
||||
|
||||
if (!hasCronChange && !hasInputChange && hasDisabledToggle) {
|
||||
if (desiredDisabled !== existing.disabled) {
|
||||
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const cron = (actionChanges.cron as string) ?? existing.cron;
|
||||
const input = coerceTriggerInput(actionChanges.input, existing.input);
|
||||
|
||||
if (!cron || !input) {
|
||||
console.error('Missing data for recurring trigger edit', action);
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedRule = await updateRecurringJobRule({
|
||||
projectId,
|
||||
ruleId: target.id,
|
||||
cron,
|
||||
input,
|
||||
});
|
||||
|
||||
if (hasDisabledToggle && desiredDisabled !== updatedRule.disabled) {
|
||||
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
|
||||
}
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
return mutated;
|
||||
}, [projectId, hasUpcomingReplacement]);
|
||||
|
||||
const handleExternalTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
|
||||
if (action.action === 'create_new') {
|
||||
const actionIndex = context?.actionIndex ?? -1;
|
||||
const messageIndex = context?.messageIndex ?? -1;
|
||||
requestTriggerSetup({ action, actionIndex, messageIndex });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action.action === 'delete') {
|
||||
const triggerList = triggersRef.current;
|
||||
const target = triggerList.find((trigger): trigger is Extract<CopilotTrigger, { type: 'external' }> => {
|
||||
if (trigger.type !== 'external') {
|
||||
return false;
|
||||
}
|
||||
const maybeName = (trigger as unknown as { name?: string }).name;
|
||||
return (
|
||||
trigger.triggerTypeName === action.name ||
|
||||
trigger.triggerTypeSlug === action.name ||
|
||||
trigger.id === action.name ||
|
||||
maybeName === action.name
|
||||
);
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
console.warn('Unable to resolve external trigger for action', action.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { deleteComposioTriggerDeployment } = await loadComposioActions();
|
||||
await deleteComposioTriggerDeployment({ projectId, deploymentId: target.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [projectId, requestTriggerSetup]);
|
||||
|
||||
const handleTriggerAction = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
|
||||
if (action.config_type === 'one_time_trigger') {
|
||||
const mutated = await handleOneTimeTrigger(action, context);
|
||||
if (mutated) {
|
||||
await refreshTriggers();
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
if (action.config_type === 'recurring_trigger') {
|
||||
const mutated = await handleRecurringTrigger(action, context);
|
||||
if (mutated) {
|
||||
await refreshTriggers();
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
if (action.config_type === 'external_trigger') {
|
||||
const mutated = await handleExternalTrigger(action, context);
|
||||
if (mutated) {
|
||||
await refreshTriggers();
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [handleOneTimeTrigger, handleRecurringTrigger, handleExternalTrigger, refreshTriggers]);
|
||||
|
||||
return {
|
||||
triggerSetupModal,
|
||||
requestTriggerSetup,
|
||||
closeTriggerSetup,
|
||||
handleTriggerCreatedViaModal: handleTriggerCreatedViaModal,
|
||||
handleTriggerAction,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue