add composio triggers (#192)

This commit is contained in:
Ramnique Singh 2025-08-08 02:27:42 +05:30 committed by GitHub
parent 5e706f0684
commit 3552302f4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 4887 additions and 111 deletions

View file

@ -0,0 +1,19 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { ConversationView } from "../components/conversation-view";
export const metadata: Metadata = {
title: "Conversation",
};
export default async function Page(
props: {
params: Promise<{ projectId: string, conversationId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <ConversationView projectId={params.projectId} conversationId={params.conversationId} />;
}

View file

@ -0,0 +1,228 @@
'use client';
import { useEffect, useMemo, useState } from "react";
import { Spinner } from "@heroui/react";
import { Panel } from "@/components/common/panel-common";
import { fetchConversation } from "@/app/actions/conversation_actions";
import { Conversation } from "@/src/entities/models/conversation";
import { Turn } from "@/src/entities/models/turn";
import { z } from "zod";
import Link from "next/link";
import { MessageDisplay } from "../../../../lib/components/message-display";
function TurnReason({ reason }: { reason: z.infer<typeof Turn>['reason'] }) {
const getReasonDisplay = () => {
switch (reason.type) {
case 'chat':
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
case 'api':
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
case 'job':
return { label: `JOB: ${reason.jobId}`, color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' };
default:
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
}
};
const { label, color } = getReasonDisplay();
return (
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
{label}
</span>
);
}
function TurnReasonWithLink({ reason, projectId }: { reason: z.infer<typeof Turn>['reason']; projectId: string }) {
const getReasonDisplay = () => {
switch (reason.type) {
case 'chat':
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
case 'api':
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
case 'job':
return {
label: `JOB: ${reason.jobId}`,
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
isJob: true,
jobId: reason.jobId
};
default:
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
}
};
const { label, color, isJob, jobId } = getReasonDisplay();
if (isJob && jobId) {
return (
<Link
href={`/projects/${projectId}/jobs/${jobId}`}
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color} hover:opacity-80 transition-opacity`}
>
{label}
</Link>
);
}
return (
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
{label}
</span>
);
}
function TurnContainer({ turn, index, projectId }: { turn: z.infer<typeof Turn>; index: number; projectId: string }) {
return (
<div id={`turn-${turn.id}`} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Turn Header */}
<div className="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-mono font-semibold text-gray-700 dark:text-gray-300">
TURN #{index + 1}
</span>
<TurnReasonWithLink reason={turn.reason} projectId={projectId} />
</div>
<div className="text-xs text-gray-500 dark:text-gray-500">
{new Date(turn.createdAt).toLocaleTimeString()}
</div>
</div>
</div>
{/* Turn Content */}
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{/* Input Messages */}
{turn.input.messages && turn.input.messages.length > 0 && (
<div className="p-4 bg-gray-50 dark:bg-gray-900/50">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
Input Messages ({turn.input.messages.length})
</div>
<div className="space-y-1">
{turn.input.messages.map((message, msgIndex) => (
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
))}
</div>
</div>
)}
{/* Output Messages */}
{turn.output && turn.output.length > 0 && (
<div className="p-4">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
Output Messages ({turn.output.length})
</div>
<div className="space-y-1">
{turn.output.map((message, msgIndex) => (
<MessageDisplay key={`output-${msgIndex}`} message={message} index={msgIndex} />
))}
</div>
</div>
)}
{/* Error Display */}
{turn.error && (
<div className="p-4 bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500">
<div className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1 uppercase tracking-wide">
Error
</div>
<div className="text-sm text-red-700 dark:text-red-300 font-mono">
{turn.error}
</div>
</div>
)}
</div>
</div>
);
}
export function ConversationView({ projectId, conversationId }: { projectId: string; conversationId: string; }) {
const [conversation, setConversation] = useState<z.infer<typeof Conversation> | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchConversation({ conversationId });
if (ignore) return;
setConversation(res);
setLoading(false);
})();
return () => { ignore = true; };
}, [conversationId]);
const title = useMemo(() => {
if (!conversation) return 'Conversation';
return `Conversation ${conversation.id}`;
}, [conversation]);
return (
<Panel
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
rightActions={<div className="flex items-center gap-3"></div>}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && conversation && (
<div className="flex flex-col gap-6">
{/* Conversation Metadata */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Conversation ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{conversation.id}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{new Date(conversation.createdAt).toLocaleString()}
</span>
</div>
{conversation.updatedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{new Date(conversation.updatedAt).toLocaleString()}
</span>
</div>
)}
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Live Workflow:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{conversation.isLiveWorkflow ? 'Yes' : 'No'}
</span>
</div>
</div>
</div>
{/* Turns */}
{conversation.turns && conversation.turns.length > 0 ? (
<div className="space-y-4">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Turns ({conversation.turns.length})
</div>
{conversation.turns.map((turn, index) => (
<TurnContainer key={turn.id} turn={turn} index={index} projectId={projectId} />
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-sm font-mono">No turns in this conversation.</div>
</div>
)}
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,151 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { listConversations } from "@/app/actions/conversation_actions";
import { z } from "zod";
import { ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
type ListedItem = z.infer<typeof ListedConversationItem>;
export function ConversationsList({ projectId }: { projectId: string }) {
const [items, setItems] = useState<ListedItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const fetchPage = useCallback(async (cursorArg?: string | null) => {
const res = await listConversations({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
return res;
}, [projectId]);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchPage(null);
if (ignore) return;
setItems(res.items);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoading(false);
})();
return () => { ignore = true; };
}, [fetchPage]);
const loadMore = useCallback(async () => {
if (!cursor) return;
setLoadingMore(true);
const res = await fetchPage(cursor);
setItems(prev => [...prev, ...res.items]);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoadingMore(false);
}, [cursor, fetchPage]);
const sections = useMemo(() => {
const groups: Record<string, ListedItem[]> = {
Today: [],
'This week': [],
'This month': [],
Older: [],
};
for (const item of items) {
const d = new Date(item.createdAt);
if (isToday(d)) groups['Today'].push(item);
else if (isThisWeek(d)) groups['This week'].push(item);
else if (isThisMonth(d)) groups['This month'].push(item);
else groups['Older'].push(item);
}
return groups;
}, [items]);
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CONVERSATIONS
</div>
</div>
}
rightActions={
<div className="flex items-center gap-3">
{/* Reserved for future actions */}
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && items.length === 0 && (
<p className="mt-4 text-center">No conversations yet.</p>
)}
{!loading && items.length > 0 && (
<div className="flex flex-col gap-8">
{Object.entries(sections).map(([label, group]) => (
group.length > 0 ? (
<div key={label}>
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Conversation</th>
<th className="w-[30%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{group.map((c) => (
<tr key={c.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-6 py-4 text-left">
<Link
href={`/projects/${projectId}/conversations/${c.id}`}
size="lg"
isBlock
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
>
{c.id}
</Link>
</td>
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
{new Date(c.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null
))}
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
size="sm"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,19 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { ConversationsList } from "./components/conversations-list";
export const metadata: Metadata = {
title: "Conversations",
};
export default async function Page(
props: {
params: Promise<{ projectId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <ConversationsList projectId={params.projectId} />;
}

View file

@ -0,0 +1,17 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { JobView } from "../components/job-view";
export const metadata: Metadata = {
title: "Job",
};
export default async function Page(
props: {
params: Promise<{ projectId: string, jobId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <JobView projectId={params.projectId} jobId={params.jobId} />;
}

View file

@ -0,0 +1,234 @@
'use client';
import { useEffect, useMemo, useState } from "react";
import { Spinner } from "@heroui/react";
import { Panel } from "@/components/common/panel-common";
import { fetchJob } from "@/app/actions/job_actions";
import { Job } from "@/src/entities/models/job";
import { z } from "zod";
import Link from "next/link";
import { MessageDisplay } from "../../../../lib/components/message-display";
export function JobView({ projectId, jobId }: { projectId: string; jobId: string; }) {
const [job, setJob] = useState<z.infer<typeof Job> | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchJob({ jobId });
if (ignore) return;
setJob(res);
setLoading(false);
})();
return () => { ignore = true; };
}, [jobId]);
const title = useMemo(() => {
if (!job) return 'Job';
return `Job ${job.id}`;
}, [job]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-600 dark:text-green-400';
case 'failed':
return 'text-red-600 dark:text-red-400';
case 'running':
return 'text-blue-600 dark:text-blue-400';
case 'pending':
return 'text-yellow-600 dark:text-yellow-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
};
const getReasonDisplay = (reason: any) => {
if (reason.type === 'composio_trigger') {
return {
type: 'Composio Trigger',
details: {
'Trigger Type': reason.triggerTypeSlug,
'Trigger ID': reason.triggerId,
'Deployment ID': reason.triggerDeploymentId,
},
payload: reason.payload
};
}
return {
type: 'Unknown',
details: {},
payload: null
};
};
// Extract conversation and turn IDs from job output
const conversationId = job?.output?.conversationId;
const turnId = job?.output?.turnId;
const reasonInfo = job ? getReasonDisplay(job.reason) : null;
return (
<Panel
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
rightActions={<div className="flex items-center gap-3"></div>}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && job && (
<div className="flex flex-col gap-6">
{/* Job Metadata */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{job.id}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<span className={`ml-2 font-mono ${getStatusColor(job.status)}`}>
{job.status}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{new Date(job.createdAt).toLocaleString()}
</span>
</div>
{job.updatedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{new Date(job.updatedAt).toLocaleString()}
</span>
</div>
)}
{conversationId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Conversation:</span>
<Link
href={`/projects/${projectId}/conversations/${conversationId}`}
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
>
{conversationId}
</Link>
</div>
)}
{turnId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Turn:</span>
<Link
href={`/projects/${projectId}/conversations/${conversationId}#turn-${turnId}`}
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
>
{turnId}
</Link>
</div>
)}
{job.output?.error && (
<div className="col-span-2">
<span className="font-semibold text-red-700 dark:text-red-300">Error:</span>
<span className="ml-2 font-mono text-red-600 dark:text-red-400">
{job.output.error}
</span>
</div>
)}
</div>
</div>
{/* Job Reason */}
{reasonInfo && (
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
Job Reason
</div>
<div className="space-y-4">
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
{reasonInfo.type}
</div>
<div className="grid grid-cols-1 gap-2 text-sm">
{Object.entries(reasonInfo.details).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="font-semibold text-gray-700 dark:text-gray-300">{key}:</span>
<span className="font-mono text-gray-600 dark:text-gray-400">{value}</span>
</div>
))}
</div>
</div>
{reasonInfo.payload && Object.keys(reasonInfo.payload).length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
Trigger Payload
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[300px]">
{JSON.stringify(reasonInfo.payload, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Job Input */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
Job Input
</div>
<div className="space-y-4">
{/* Messages */}
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
Messages ({job.input.messages.length})
</div>
<div className="space-y-1">
{job.input.messages.map((message, msgIndex) => (
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
))}
</div>
</div>
{/* Workflow */}
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
Workflow
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[400px]">
{JSON.stringify(job.input.workflow, null, 2)}
</pre>
</div>
</div>
</div>
{/* Job Output */}
{job.output && (
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
Job Output
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono">
{JSON.stringify(job.output, null, 2)}
</pre>
</div>
)}
</div>
)}
{!loading && !job && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-sm font-mono">Job not found.</div>
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,183 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { listJobs } from "@/app/actions/job_actions";
import { z } from "zod";
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
type ListedItem = z.infer<typeof ListedJobItem>;
export function JobsList({ projectId }: { projectId: string }) {
const [items, setItems] = useState<ListedItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const fetchPage = useCallback(async (cursorArg?: string | null) => {
const res = await listJobs({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
return res;
}, [projectId]);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchPage(null);
if (ignore) return;
setItems(res.items);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoading(false);
})();
return () => { ignore = true; };
}, [fetchPage]);
const loadMore = useCallback(async () => {
if (!cursor) return;
setLoadingMore(true);
const res = await fetchPage(cursor);
setItems(prev => [...prev, ...res.items]);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoadingMore(false);
}, [cursor, fetchPage]);
const sections = useMemo(() => {
const groups: Record<string, ListedItem[]> = {
Today: [],
'This week': [],
'This month': [],
Older: [],
};
for (const item of items) {
const d = new Date(item.createdAt);
if (isToday(d)) groups['Today'].push(item);
else if (isThisWeek(d)) groups['This week'].push(item);
else if (isThisMonth(d)) groups['This month'].push(item);
else groups['Older'].push(item);
}
return groups;
}, [items]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-600 dark:text-green-400';
case 'failed':
return 'text-red-600 dark:text-red-400';
case 'running':
return 'text-blue-600 dark:text-blue-400';
case 'pending':
return 'text-yellow-600 dark:text-yellow-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
};
const getReasonDisplay = (reason: any) => {
if (reason.type === 'composio_trigger') {
return `Composio: ${reason.triggerTypeSlug}`;
}
return 'Unknown';
};
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
JOBS
</div>
</div>
}
rightActions={
<div className="flex items-center gap-3">
{/* Reserved for future actions */}
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && items.length === 0 && (
<p className="mt-4 text-center">No jobs yet.</p>
)}
{!loading && items.length > 0 && (
<div className="flex flex-col gap-8">
{Object.entries(sections).map(([label, group]) => (
group.length > 0 ? (
<div key={label}>
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Job</th>
<th className="w-[20%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</th>
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{group.map((job) => (
<tr key={job.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-6 py-4 text-left">
<Link
href={`/projects/${projectId}/jobs/${job.id}`}
size="lg"
isBlock
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
>
{job.id}
</Link>
</td>
<td className="px-6 py-4 text-left">
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
{job.status}
</span>
</td>
<td className="px-6 py-4 text-left">
<span className="text-sm text-gray-600 dark:text-gray-300 font-mono">
{getReasonDisplay(job.reason)}
</span>
</td>
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
{new Date(job.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null
))}
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
size="sm"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,17 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { JobsList } from "./components/jobs-list";
export const metadata: Metadata = {
title: "Jobs",
};
export default async function Page(
props: {
params: Promise<{ projectId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <JobsList projectId={params.projectId} />;
}

View file

@ -10,34 +10,31 @@ import { getProjectConfig } from '@/app/actions/project_actions';
import { z } from 'zod';
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { ComposioToolsPanel } from './ComposioToolsPanel';
import { ToolkitCard } from './ToolkitCard';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { Workflow } from '@/app/lib/types/workflow_types';
type ToolkitType = z.infer<typeof ZToolkit>;
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
type ProjectType = z.infer<typeof Project>;
interface ComposioProps {
interface SelectComposioToolkitProps {
projectId: string;
tools: z.infer<typeof Workflow.shape.tools>;
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
onSelectToolkit: (toolkit: ToolkitType) => void;
initialToolkitSlug?: string | null;
}
export function Composio({
export function SelectComposioToolkit({
projectId,
tools,
onAddTool,
onSelectToolkit,
initialToolkitSlug
}: ComposioProps) {
}: SelectComposioToolkitProps) {
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const loadProjectConfig = useCallback(async () => {
try {
@ -84,14 +81,8 @@ export function Composio({
}, [projectId]);
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
}, []);
const handleCloseToolsPanel = useCallback(() => {
setSelectedToolkit(null);
setIsToolsPanelOpen(false);
}, []);
onSelectToolkit(toolkit);
}, [onSelectToolkit]);
useEffect(() => {
loadProjectConfig();
@ -106,11 +97,10 @@ export function Composio({
if (initialToolkitSlug && toolkits.length > 0) {
const toolkit = toolkits.find(t => t.slug === initialToolkitSlug);
if (toolkit) {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
onSelectToolkit(toolkit);
}
}
}, [initialToolkitSlug, toolkits]);
}, [initialToolkitSlug, toolkits, onSelectToolkit]);
const filteredToolkits = toolkits.filter(toolkit => {
const searchLower = searchQuery.toLowerCase();
@ -226,15 +216,6 @@ export function Composio({
</p>
</div>
)}
{/* Tools Panel */}
{selectedToolkit && <ComposioToolsPanel
toolkit={selectedToolkit}
isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel}
tools={tools}
onAddTool={onAddTool}
/>}
</div>
);
}

View file

@ -3,10 +3,12 @@
import { useState } from 'react';
import { Tabs, Tab } from '@/components/ui/tabs';
import { CustomMcpServers } from './CustomMcpServer';
import { Composio } from './Composio';
import { SelectComposioToolkit } from './SelectComposioToolkit';
import { ComposioToolsPanel } from './ComposioToolsPanel';
import { AddWebhookTool } from './AddWebhookTool';
import type { Key } from 'react';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { ZToolkit } from '@/app/lib/composio/composio';
import { z } from 'zod';
interface ToolsConfigProps {
@ -17,6 +19,8 @@ interface ToolsConfigProps {
initialToolkitSlug?: string | null;
}
type ToolkitType = z.infer<typeof ZToolkit>;
export function ToolsConfig({
projectId,
useComposioTools,
@ -29,11 +33,28 @@ export function ToolsConfig({
defaultActiveTab = 'composio';
}
const [activeTab, setActiveTab] = useState(defaultActiveTab);
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const handleTabChange = (key: Key) => {
setActiveTab(key.toString());
};
const handleSelectToolkit = (toolkit: ToolkitType) => {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
};
const handleCloseToolsPanel = () => {
setSelectedToolkit(null);
setIsToolsPanelOpen(false);
};
const handleAddTool = (tool: z.infer<typeof WorkflowTool>) => {
onAddTool(tool);
handleCloseToolsPanel();
};
return (
<div className="h-full flex flex-col">
<Tabs
@ -46,10 +67,10 @@ export function ToolsConfig({
{useComposioTools && (
<Tab key="composio" title="Composio">
<div className="mt-4 p-6">
<Composio
<SelectComposioToolkit
projectId={projectId}
tools={tools}
onAddTool={onAddTool}
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={initialToolkitSlug}
/>
</div>
@ -72,6 +93,17 @@ export function ToolsConfig({
</div>
</Tab>
</Tabs>
{/* Tools Panel */}
{selectedToolkit && (
<ComposioToolsPanel
toolkit={selectedToolkit}
isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel}
tools={tools}
onAddTool={handleAddTool}
/>
)}
</div>
);
}

View file

@ -0,0 +1,207 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Button, Card, CardBody, CardHeader, Spinner } from '@heroui/react';
import { ChevronLeft, ChevronRight, ZapIcon, ArrowLeft } from 'lucide-react';
import { z } from 'zod';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { listComposioTriggerTypes } from '@/app/actions/composio_actions';
import { ZToolkit } from '@/app/lib/composio/composio';
interface ComposioTriggerTypesPanelProps {
toolkit: z.infer<typeof ZToolkit>;
onBack: () => void;
onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void;
}
type TriggerType = z.infer<typeof ComposioTriggerType>;
export function ComposioTriggerTypesPanel({
toolkit,
onBack,
onSelectTriggerType,
}: ComposioTriggerTypesPanelProps) {
const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => {
try {
if (resetList) {
setLoading(true);
setTriggerTypes([]);
} else {
setLoadingMore(true);
}
setError(null);
const response = await listComposioTriggerTypes(toolkit.slug, nextCursor);
if (resetList) {
setTriggerTypes(response.items);
} else {
setTriggerTypes(prev => [...prev, ...response.items]);
}
setCursor(response.nextCursor);
setHasNextPage(!!response.nextCursor);
} catch (err: any) {
console.error('Error loading trigger types:', err);
setError('Failed to load trigger types. Please try again.');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [toolkit.slug]);
const handleLoadMore = () => {
if (cursor && !loadingMore) {
loadTriggerTypes(false, cursor);
}
};
const handleTriggerTypeSelect = (triggerType: TriggerType) => {
onSelectTriggerType(triggerType);
};
useEffect(() => {
loadTriggerTypes(true);
}, [loadTriggerTypes]);
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{toolkit.name} Triggers
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a trigger type to set up
</p>
</div>
</div>
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
<span className="ml-2">Loading trigger types...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{toolkit.name} Triggers
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a trigger type to set up
</p>
</div>
</div>
<div className="text-center py-12">
<p className="text-red-500 mb-4">{error}</p>
<Button variant="flat" onPress={() => loadTriggerTypes(true)}>
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{toolkit.name} Triggers
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a trigger type to set up ({triggerTypes.length} available)
</p>
</div>
</div>
{triggerTypes.length === 0 ? (
<div className="text-center py-12">
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No trigger types available
</h3>
<p className="text-gray-500 dark:text-gray-400">
This toolkit doesn&apos;t have any trigger types configured.
</p>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{triggerTypes.map((triggerType) => (
<Card
key={triggerType.slug}
className="cursor-pointer hover:shadow-md transition-shadow"
isPressable
onPress={() => handleTriggerTypeSelect(triggerType)}
>
<CardHeader className="flex gap-3">
<div className="flex items-center justify-center w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<ZapIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex flex-col">
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">
{triggerType.name}
</p>
</div>
</CardHeader>
<CardBody className="pt-0">
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{triggerType.description}
</p>
<div className="mt-3 flex justify-end">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleTriggerTypeSelect(triggerType)}
>
Configure
</Button>
</div>
</CardBody>
</Card>
))}
</div>
{hasNextPage && (
<div className="flex justify-center pt-4">
<Button
variant="flat"
onPress={handleLoadMore}
isLoading={loadingMore}
startContent={!loadingMore ? <ChevronRight className="w-4 h-4" /> : null}
>
{loadingMore ? 'Loading...' : 'Load More'}
</Button>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,263 @@
'use client';
import React, { useState, useCallback } from 'react';
import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react';
import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react';
import { z } from 'zod';
import { ZToolkit } from '@/app/lib/composio/composio';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
interface TriggerConfigFormProps {
toolkit: z.infer<typeof ZToolkit>;
triggerType: z.infer<typeof ComposioTriggerType>;
onBack: () => void;
onSubmit: (config: Record<string, unknown>) => void;
isSubmitting?: boolean;
}
interface JsonSchemaProperty {
type: string;
title?: string;
description?: string;
default?: any;
enum?: any[];
}
interface JsonSchema {
type: 'object';
properties: Record<string, JsonSchemaProperty>;
required?: string[];
title?: string;
}
export function TriggerConfigForm({
toolkit,
triggerType,
onBack,
onSubmit,
isSubmitting = false,
}: TriggerConfigFormProps) {
const [formData, setFormData] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
// Parse the JSON schema from triggerType.config
const schema = triggerType.config as JsonSchema;
const handleSubmit = useCallback(() => {
// Validate required fields
const newErrors: Record<string, string> = {};
if (schema.required) {
schema.required.forEach(fieldName => {
if (!formData[fieldName] || formData[fieldName].trim() === '') {
const field = schema.properties[fieldName];
newErrors[fieldName] = `${field?.title || fieldName} is required`;
}
});
}
setErrors(newErrors);
// If no errors, submit the form
if (Object.keys(newErrors).length === 0) {
// Convert form data to appropriate types based on schema
const processedData: Record<string, unknown> = {};
Object.entries(formData).forEach(([key, value]) => {
const property = schema.properties[key];
if (property) {
switch (property.type) {
case 'number':
case 'integer':
processedData[key] = value ? Number(value) : undefined;
break;
case 'boolean':
processedData[key] = value === 'true';
break;
default:
processedData[key] = value;
}
}
});
onSubmit(processedData);
}
}, [formData, schema, onSubmit]);
const handleFieldChange = useCallback((fieldName: string, value: string) => {
setFormData(prev => ({ ...prev, [fieldName]: value }));
// Clear error for this field if it exists
if (errors[fieldName]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
}
}, [errors]);
// Check if trigger requires configuration
const hasConfigFields = schema && schema.properties && Object.keys(schema.properties).length > 0;
if (!hasConfigFields) {
// No configuration needed - show success state
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{triggerType.name} Configuration
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
No additional configuration required
</p>
</div>
</div>
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="relative">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<ZapIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div className="absolute -top-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
<CheckCircleIcon className="w-4 h-4 text-white" />
</div>
</div>
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
Ready to Create Trigger!
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
This trigger type doesn&apos;t require additional configuration. You can create it directly.
</p>
<Button
color="primary"
size="lg"
onPress={() => onSubmit({})}
isLoading={isSubmitting}
>
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Configure {triggerType.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{triggerType.description}
</p>
</div>
</div>
<Card>
<CardHeader>
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
Trigger Configuration
</h4>
</CardHeader>
<CardBody>
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
Configure the settings for your {toolkit.name} trigger:
</div>
<div className="space-y-4">
{Object.entries(schema.properties).map(([fieldName, property]) => {
const isRequired = schema.required?.includes(fieldName) || false;
const fieldValue = formData[fieldName] || '';
const fieldError = errors[fieldName];
// Handle different input types based on property type
if (property.enum) {
// Render select for enum fields
return (
<div key={fieldName}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{property.title || fieldName}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<select
value={fieldValue}
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
required={isRequired}
>
<option value="">Select {property.title || fieldName}</option>
{property.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{property.description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{property.description}
</p>
)}
{fieldError && (
<p className="mt-1 text-xs text-red-500">{fieldError}</p>
)}
</div>
);
}
return (
<Input
key={fieldName}
label={property.title || fieldName}
placeholder={property.description || `Enter ${property.title || fieldName}`}
value={fieldValue}
onValueChange={(value) => handleFieldChange(fieldName, value)}
isRequired={isRequired}
type={property.type === 'number' || property.type === 'integer' ? 'number' : 'text'}
variant="bordered"
description={property.description}
isInvalid={!!fieldError}
errorMessage={fieldError}
/>
);
})}
</div>
</div>
</CardBody>
</Card>
<div className="flex justify-end gap-3">
<Button
variant="bordered"
onPress={onBack}
isDisabled={isSubmitting}
>
Back
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={isSubmitting}
>
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,359 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Spinner, Card, CardBody, CardHeader } from '@heroui/react';
import { Plus, Trash2, ZapIcon } from 'lucide-react';
import { z } from 'zod';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { listComposioTriggerDeployments, deleteComposioTriggerDeployment, createComposioTriggerDeployment } from '@/app/actions/composio_actions';
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
import { ComposioTriggerTypesPanel } from './ComposioTriggerTypesPanel';
import { TriggerConfigForm } from './TriggerConfigForm';
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
import { ZToolkit } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
interface TriggersModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
projectConfig: z.infer<typeof Project>;
onProjectConfigUpdated?: () => void;
}
type TriggerDeployment = z.infer<typeof ComposioTriggerDeployment>;
export function TriggersModal({
isOpen,
onClose,
projectId,
projectConfig,
onProjectConfigUpdated,
}: TriggersModalProps) {
const [triggers, setTriggers] = useState<TriggerDeployment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateFlow, setShowCreateFlow] = useState(false);
const [selectedToolkit, setSelectedToolkit] = useState<z.infer<typeof ZToolkit> | null>(null);
const [selectedTriggerType, setSelectedTriggerType] = useState<z.infer<typeof ComposioTriggerType> | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isSubmittingTrigger, setIsSubmittingTrigger] = useState(false);
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
const loadTriggers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await listComposioTriggerDeployments({ projectId });
setTriggers(response.items);
} catch (err: any) {
console.error('Error loading triggers:', err);
setError('Failed to load triggers. Please try again.');
} finally {
setLoading(false);
}
}, [projectId]);
const handleDeleteTrigger = async (deploymentId: string) => {
if (!window.confirm('Are you sure you want to delete this trigger?')) {
return;
}
try {
setDeletingTrigger(deploymentId);
await deleteComposioTriggerDeployment({ projectId, deploymentId });
await loadTriggers(); // Reload the list
} catch (err: any) {
console.error('Error deleting trigger:', err);
setError('Failed to delete trigger. Please try again.');
} finally {
setDeletingTrigger(null);
}
};
const handleCreateNew = () => {
setShowCreateFlow(true);
};
const handleBackToList = () => {
setShowCreateFlow(false);
setSelectedToolkit(null);
setSelectedTriggerType(null);
setShowAuthModal(false);
setIsSubmittingTrigger(false);
loadTriggers(); // Reload in case any triggers were created
};
const handleSelectToolkit = (toolkit: z.infer<typeof ZToolkit>) => {
setSelectedToolkit(toolkit);
};
const handleBackToToolkitSelection = () => {
setSelectedToolkit(null);
setSelectedTriggerType(null);
setIsSubmittingTrigger(false);
};
const handleSelectTriggerType = (triggerType: z.infer<typeof ComposioTriggerType>) => {
if (!selectedToolkit) return;
setSelectedTriggerType(triggerType);
// Check if toolkit requires auth and if connected account exists
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (needsAuth && !hasConnection) {
// Show auth modal
setShowAuthModal(true);
} else {
// Proceed to trigger configuration
// For now this is just the placeholder, but will be actual config later
}
};
const handleAuthComplete = async () => {
setShowAuthModal(false);
onProjectConfigUpdated?.();
};
const handleTriggerSubmit = async (triggerConfig: Record<string, unknown>) => {
if (!selectedToolkit || !selectedTriggerType) return;
try {
setIsSubmittingTrigger(true);
// Get the connected account ID for this toolkit
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
if (!connectedAccountId) {
throw new Error('No connected account found for this toolkit');
}
// Create the trigger deployment
await createComposioTriggerDeployment({
projectId,
toolkitSlug: selectedToolkit.slug,
triggerTypeSlug: selectedTriggerType.slug,
connectedAccountId,
triggerConfig,
});
// Success! Go back to triggers list and reload
handleBackToList();
} catch (err: any) {
console.error('Error creating trigger:', err);
setError('Failed to create trigger. Please try again.');
} finally {
setIsSubmittingTrigger(false);
}
};
useEffect(() => {
if (isOpen && !showCreateFlow) {
loadTriggers();
}
}, [isOpen, showCreateFlow, loadTriggers]);
const renderTriggerList = () => {
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
<span className="ml-2">Loading triggers...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<p className="text-red-500 mb-4">{error}</p>
<Button variant="flat" onPress={loadTriggers}>
Try Again
</Button>
</div>
);
}
if (triggers.length === 0) {
return (
<div className="text-center py-12">
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No triggers configured
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6">
Set up your first trigger to listen for events from your connected apps.
</p>
<Button
color="primary"
variant="solid"
startContent={<Plus className="w-4 h-4" />}
onPress={handleCreateNew}
>
Create your first trigger
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Active Triggers ({triggers.length})
</h3>
<Button
color="primary"
variant="solid"
startContent={<Plus className="w-4 h-4" />}
onPress={handleCreateNew}
>
Create New Trigger
</Button>
</div>
<div className="space-y-3">
{triggers.map((trigger) => (
<Card key={trigger.id} className="w-full">
<CardHeader className="flex justify-between items-start">
<div>
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
{trigger.triggerTypeSlug}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Created {new Date(trigger.createdAt).toLocaleDateString()}
</p>
</div>
<Button
isIconOnly
variant="light"
color="danger"
size="sm"
isLoading={deletingTrigger === trigger.id}
onPress={() => handleDeleteTrigger(trigger.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</CardHeader>
<CardBody className="pt-0">
<div className="text-sm text-gray-600 dark:text-gray-300">
<p><strong>Trigger ID:</strong> {trigger.triggerId}</p>
<p><strong>Connected Account:</strong> {trigger.connectedAccountId}</p>
{Object.keys(trigger.triggerConfig).length > 0 && (
<div className="mt-2">
<strong>Configuration:</strong>
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded">
{JSON.stringify(trigger.triggerConfig, null, 2)}
</pre>
</div>
)}
</div>
</CardBody>
</Card>
))}
</div>
</div>
);
};
const renderCreateFlow = () => {
// If trigger type is selected and auth is complete, show config
if (selectedToolkit && selectedTriggerType && !showAuthModal) {
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (!needsAuth || hasConnection) {
return (
<TriggerConfigForm
toolkit={selectedToolkit}
triggerType={selectedTriggerType}
onBack={handleBackToToolkitSelection}
onSubmit={handleTriggerSubmit}
isSubmitting={isSubmittingTrigger}
/>
);
}
}
// If no toolkit selected, show toolkit selection
if (!selectedToolkit) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Select a Toolkit to Create Trigger
</h3>
<Button
variant="flat"
onPress={handleBackToList}
>
Back to Triggers
</Button>
</div>
<SelectComposioToolkit
projectId={projectId}
tools={[]} // Empty array since we're not using this for tools
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={null}
/>
</div>
);
}
// If toolkit selected, show trigger types
return (
<div className="space-y-4">
<ComposioTriggerTypesPanel
toolkit={selectedToolkit}
onBack={handleBackToToolkitSelection}
onSelectTriggerType={handleSelectTriggerType}
/>
</div>
);
};
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="5xl"
scrollBehavior="inside"
>
<ModalContent className="max-h-[90vh]">
<ModalHeader>
<div className="flex items-center gap-2">
<ZapIcon className="w-5 h-5" />
<span>Manage Triggers</span>
</div>
</ModalHeader>
<ModalBody>
{showCreateFlow ? renderCreateFlow() : renderTriggerList()}
</ModalBody>
{!showCreateFlow && (
<ModalFooter>
<Button variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
)}
</ModalContent>
</Modal>
{/* Auth Modal */}
{selectedToolkit && (
<ToolkitAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={selectedToolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -26,7 +26,7 @@ import { publishWorkflow } from "@/app/actions/project_actions";
import { saveWorkflow } from "@/app/actions/project_actions";
import { updateProjectName } from "@/app/actions/project_actions";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon } from "lucide-react";
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon, ZapIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { ProductTour } from "@/components/common/product-tour";
import { ModelsResponse } from "@/app/lib/types/billing_types";
@ -37,6 +37,7 @@ import { ConfigApp } from "../config/app";
import { InputField } from "@/app/lib/components/input-field";
import { VoiceSection } from "../config/components/voice";
import { ChatWidgetSection } from "../config/components/project";
import { TriggersModal } from "./components/TriggersModal";
enablePatches();
@ -882,6 +883,9 @@ export function WorkflowEditor({
// Modal state for chat widget configuration
const { isOpen: isChatWidgetModalOpen, onOpen: onChatWidgetModalOpen, onClose: onChatWidgetModalClose } = useDisclosure();
// Modal state for triggers management
const { isOpen: isTriggersModalOpen, onOpen: onTriggersModalOpen, onClose: onTriggersModalClose } = useDisclosure();
// Project name state
const [localProjectName, setLocalProjectName] = useState<string>(projectConfig.name || '');
const [projectNameError, setProjectNameError] = useState<string | null>(null);
@ -1359,6 +1363,13 @@ export function WorkflowEditor({
>
Chat widget
</DropdownItem>
<DropdownItem
key="manage-triggers"
startContent={<ZapIcon size={16} />}
onPress={onTriggersModalOpen}
>
Manage triggers
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
@ -1647,6 +1658,15 @@ export function WorkflowEditor({
</ModalBody>
</ModalContent>
</Modal>
{/* Triggers Management Modal */}
<TriggersModal
isOpen={isTriggersModalOpen}
onClose={onTriggersModalClose}
projectId={projectId}
projectConfig={projectConfig}
onProjectConfigUpdated={onProjectConfigUpdated}
/>
</div>
</EntitySelectionContext.Provider>
);

View file

@ -13,7 +13,9 @@ import {
ChevronRightIcon,
Moon,
Sun,
HelpCircle
HelpCircle,
MessageSquareIcon,
LogsIcon
} from "lucide-react";
import { getProjectConfig } from "@/app/actions/project_actions";
import { useTheme } from "@/app/providers/theme-provider";
@ -60,6 +62,18 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
icon: WorkflowIcon,
requiresProject: true
},
{
href: 'conversations',
label: 'Conversations',
icon: MessageSquareIcon,
requiresProject: true
},
{
href: 'jobs',
label: 'Jobs',
icon: LogsIcon,
requiresProject: true
},
{
href: 'config',
label: 'Settings',