Merge remote-tracking branch 'origin/main' into feat/text-chat

This commit is contained in:
Abhishek Kumar 2026-05-21 07:47:07 +05:30
commit 129a6d700c
160 changed files with 9287 additions and 3935 deletions

View file

@ -1,237 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet, getIntegrationsApiV1IntegrationGet } from '@/client/sdk.gen';
import type { IntegrationResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import logger from '@/lib/logger';
interface GoogleSheetSelectorProps {
accessToken: string;
onSheetSelected: (sheetUrl: string, sheetName: string) => void;
selectedSheetUrl?: string;
}
interface PickerBuilder {
addView: (viewId: string) => PickerBuilder;
setOAuthToken: (token: string) => PickerBuilder;
setDeveloperKey: (key: string) => PickerBuilder;
setCallback: (callback: (data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => void) => PickerBuilder;
setTitle: (title: string) => PickerBuilder;
build: () => { setVisible: (visible: boolean) => void };
}
declare global {
interface Window {
gapi: {
load: (library: string, callback: () => void) => void;
};
google: {
picker: {
PickerBuilder: new () => PickerBuilder;
ViewId: {
SPREADSHEETS: string;
};
Action: {
PICKED: string;
};
};
};
}
}
// Google API configuration
const GOOGLE_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || '';
export default function GoogleSheetSelector({ accessToken, onSheetSelected, selectedSheetUrl }: GoogleSheetSelectorProps) {
const [loading, setLoading] = useState(false);
const [pickerApiLoaded, setPickerApiLoaded] = useState(false);
const [googleIntegration, setGoogleIntegration] = useState<IntegrationResponse | null>(null);
const [selectedSheetName, setSelectedSheetName] = useState<string>('');
const [checkingIntegration, setCheckingIntegration] = useState(true);
// Load Google Picker API
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://apis.google.com/js/api.js';
script.onload = () => {
window.gapi.load('picker', () => {
setPickerApiLoaded(true);
logger.info('Google Picker API loaded');
});
};
document.body.appendChild(script);
return () => {
if (document.body.contains(script)) {
document.body.removeChild(script);
}
};
}, []);
// Check for Google Sheet integration
useEffect(() => {
const checkGoogleIntegration = async () => {
if (!accessToken) {
return;
}
try {
const response = await getIntegrationsApiV1IntegrationGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
const integrations = Array.isArray(response.data) ? response.data : [response.data];
const googleSheet = integrations.find((i: IntegrationResponse) => i.provider === 'google-sheet');
setGoogleIntegration(googleSheet || null);
}
} catch (error) {
logger.error('Failed to check Google integration:', error);
} finally {
setCheckingIntegration(false);
}
};
checkGoogleIntegration();
}, [accessToken]);
const fetchGoogleAccessToken = async () => {
if (!googleIntegration) return null;
try {
const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({
path: {
integration_id: googleIntegration.id,
},
headers: {
Authorization: `Bearer ${accessToken}`,
}
});
if (response.data?.access_token) {
return response.data.access_token;
}
return null;
} catch (error) {
logger.error('Failed to fetch Google access token:', error);
return null;
}
};
const openGooglePicker = async () => {
if (!pickerApiLoaded) {
toast.error('Google Picker is still loading. Please try again.');
return;
}
if (!GOOGLE_API_KEY) {
toast.error('Google API Key is not configured.');
return;
}
if (!googleIntegration) {
toast.error('Please connect Google Sheets in the Integrations page first.');
return;
}
setLoading(true);
try {
const token = await fetchGoogleAccessToken();
if (!token) {
toast.error('Failed to get Google access token. Please re-authorize in Integrations.');
setLoading(false);
return;
}
const picker = new window.google.picker.PickerBuilder()
.addView(window.google.picker.ViewId.SPREADSHEETS)
.setOAuthToken(token)
.setDeveloperKey(GOOGLE_API_KEY)
.setCallback((data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => {
if (data.action === window.google.picker.Action.PICKED && data.docs && data.docs.length > 0) {
const doc = data.docs[0];
setSelectedSheetName(doc.name);
onSheetSelected(doc.url, doc.name);
toast.success(`Selected: ${doc.name}`);
}
setLoading(false);
})
.setTitle('Select a Google Sheet for your campaign')
.build();
picker.setVisible(true);
} catch (error) {
toast.error('Error opening Google Picker');
logger.error('Error opening Google Picker:', error);
setLoading(false);
}
};
if (checkingIntegration) {
return (
<div className="space-y-2">
<Label>Google Sheet</Label>
<div className="text-sm text-muted-foreground">Checking Google integration...</div>
</div>
);
}
if (!googleIntegration) {
return (
<div className="space-y-2">
<Label>Google Sheet</Label>
<div className="p-4 border border-amber-200 bg-amber-50 rounded-md">
<p className="text-sm text-amber-800 mb-2">
Google Sheets integration not found
</p>
<p className="text-sm text-amber-700">
Please go to the{' '}
<a href="/integrations" className="text-amber-900 underline font-medium">
Integrations page
</a>
{' '}and connect your Google account first.
</p>
</div>
</div>
);
}
return (
<div className="space-y-2">
<Label>Google Sheet</Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
onClick={openGooglePicker}
disabled={loading}
>
{loading ? 'Opening...' : 'Select Google Sheet'}
</Button>
{selectedSheetUrl && (
<div className="flex-1 text-sm">
<span className="text-muted-foreground">Selected: </span>
<a
href={selectedSheetUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{selectedSheetName || selectedSheetUrl}
</a>
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
Select a Google Sheet from your connected Google account
</p>
</div>
);
}

View file

@ -30,7 +30,6 @@ import { useAuth } from '@/lib/auth';
import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../CampaignAdvancedSettings';
import CsvUploadSelector from '../CsvUploadSelector';
import GoogleSheetSelector from '../GoogleSheetSelector';
export default function NewCampaignPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
@ -39,12 +38,11 @@ export default function NewCampaignPage() {
// Form state
const [campaignName, setCampaignName] = useState('');
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>('');
const [sourceType, setSourceType] = useState<'google-sheet' | 'csv'>('csv');
const [sourceType, setSourceType] = useState<'csv'>('csv');
const [sourceId, setSourceId] = useState('');
const [selectedFileName, setSelectedFileName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [userAccessToken, setUserAccessToken] = useState<string>('');
// Workflows state
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
@ -97,7 +95,6 @@ export default function NewCampaignPage() {
if (!user) return;
try {
const accessToken = await getAccessToken();
setUserAccessToken(accessToken);
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
@ -342,12 +339,6 @@ export default function NewCampaignPage() {
router.push('/campaigns');
};
// Handle sheet selection
const handleSheetSelected = (sheetUrl: string) => {
setSourceId(sheetUrl);
setCreateError(null);
};
// Handle CSV file upload
const handleFileUploaded = (fileKey: string, fileName: string) => {
setSourceId(fileKey);
@ -481,7 +472,7 @@ export default function NewCampaignPage() {
<Select
value={sourceType}
onValueChange={(value) => {
setSourceType(value as 'google-sheet' | 'csv');
setSourceType(value as 'csv');
setSourceId('');
setSelectedFileName('');
}}
@ -491,7 +482,6 @@ export default function NewCampaignPage() {
<SelectValue placeholder="Select source type" />
</SelectTrigger>
<SelectContent>
{/* <SelectItem value="google-sheet">Google Sheet</SelectItem> */}
<SelectItem value="csv">CSV File</SelectItem>
</SelectContent>
</Select>
@ -500,18 +490,10 @@ export default function NewCampaignPage() {
</p>
</div>
{sourceType === 'google-sheet' ? (
<GoogleSheetSelector
accessToken={userAccessToken}
onSheetSelected={handleSheetSelected}
selectedSheetUrl={sourceId}
/>
) : (
<CsvUploadSelector
onFileUploaded={handleFileUploaded}
selectedFileName={selectedFileName}
/>
)}
<CsvUploadSelector
onFileUploaded={handleFileUploaded}
selectedFileName={selectedFileName}
/>
{/* Advanced Settings */}
<Collapsible

View file

@ -1,69 +0,0 @@
'use client';
import Nango from '@nangohq/frontend';
import { useState } from 'react';
import { createSessionApiV1IntegrationSessionPost } from "@/client/sdk.gen";
import { Button } from '@/components/ui/button';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
export default function CreateIntegrationButton() {
const [isLoading, setIsLoading] = useState(false);
const { user, getAccessToken } = useAuth();
const handleCreateIntegration = async () => {
setIsLoading(true);
try {
if (!user) {
throw new Error('User not authenticated');
}
const accessToken = await getAccessToken();
// Fetch session details from our API
const sessionResponse = await createSessionApiV1IntegrationSessionPost({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!sessionResponse.data?.session_token) {
throw new Error('Failed to get session token');
}
// Initialize Nango and open connect UI
const nango = new Nango();
const connect = nango.openConnectUI({
onEvent: (event) => {
if (event.type === 'close') {
// Handle modal closed
setIsLoading(false);
logger.info('Nango connect UI closed');
} else if (event.type === 'connect') {
// Handle auth flow successful
setIsLoading(false);
logger.info('Integration connected successfully');
// Refresh the page to show new integrations
window.location.reload();
}
},
});
// Set the session token to initialize the connect UI
connect.setSessionToken(sessionResponse.data.session_token);
} catch (err) {
logger.error(`Error creating integration: ${err}`);
setIsLoading(false);
// You might want to show a toast notification here
alert('Failed to create integration. Please try again.');
}
};
return (
<Button onClick={handleCreateIntegration} disabled={isLoading}>
{isLoading ? 'Loading...' : 'Create Integration'}
</Button>
);
}

View file

@ -1,390 +0,0 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet } from '@/client/sdk.gen';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
interface Email {
id: string;
threadId: string;
subject: string;
from: string;
snippet: string;
date: string;
}
interface EmailDetail {
id: string;
threadId: string;
subject: string;
from: string;
to: string;
date: string;
body: string;
}
interface GmailHeader {
name: string;
value: string;
}
interface GmailPayloadPart {
mimeType: string;
body: {
data?: string;
};
}
export default function GmailSearchPage() {
const params = useParams();
const router = useRouter();
const integrationId = parseInt(params.id as string);
const { getAccessToken, redirectToLogin } = useAuth();
const [accessToken, setAccessToken] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [emails, setEmails] = useState<Email[]>([]);
const [selectedEmail, setSelectedEmail] = useState<EmailDetail | null>(null);
const [replyText, setReplyText] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sendingReply, setSendingReply] = useState(false);
const fetchAccessToken = useCallback(async () => {
try {
const token = await getAccessToken();
if (!token) {
redirectToLogin();
return;
}
const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({
path: { integration_id: integrationId },
headers: { Authorization: `Bearer ${token}` },
});
if (response.data?.access_token) {
setAccessToken(response.data.access_token);
} else {
setError('Failed to get access token');
}
} catch (err) {
logger.error('Error fetching access token:', err);
setError('Failed to fetch access token. Please try again.');
}
}, [getAccessToken, redirectToLogin, integrationId]);
useEffect(() => {
fetchAccessToken();
}, [fetchAccessToken]);
const searchEmails = async () => {
if (!accessToken || !searchQuery.trim()) return;
setLoading(true);
setError(null);
setEmails([]);
setSelectedEmail(null);
try {
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(searchQuery)}&maxResults=20`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error(`Gmail API error: ${response.statusText}`);
}
const data = await response.json();
if (!data.messages || data.messages.length === 0) {
setEmails([]);
return;
}
// Fetch details for each message
const emailPromises = data.messages.map(async (msg: { id: string }) => {
const msgResponse = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
return msgResponse.json();
});
const emailDetails = await Promise.all(emailPromises);
const formattedEmails: Email[] = emailDetails.map((email) => {
const headers = email.payload.headers as GmailHeader[];
const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject';
const from = headers.find((h) => h.name === 'From')?.value || 'Unknown';
const date = headers.find((h) => h.name === 'Date')?.value || '';
return {
id: email.id,
threadId: email.threadId,
subject,
from,
snippet: email.snippet || '',
date,
};
});
setEmails(formattedEmails);
} catch (err) {
logger.error('Error searching emails:', err);
setError('Failed to search emails. Please try again.');
} finally {
setLoading(false);
}
};
const loadEmailDetail = async (emailId: string) => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${emailId}?format=full`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error(`Gmail API error: ${response.statusText}`);
}
const email = await response.json();
const headers = email.payload.headers as GmailHeader[];
const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject';
const from = headers.find((h) => h.name === 'From')?.value || 'Unknown';
const to = headers.find((h) => h.name === 'To')?.value || 'Unknown';
const date = headers.find((h) => h.name === 'Date')?.value || '';
// Extract email body
let body = '';
if (email.payload.body.data) {
body = atob(email.payload.body.data.replace(/-/g, '+').replace(/_/g, '/'));
} else if (email.payload.parts) {
const parts = email.payload.parts as GmailPayloadPart[];
const textPart = parts.find((part) => part.mimeType === 'text/plain');
if (textPart && textPart.body.data) {
body = atob(textPart.body.data.replace(/-/g, '+').replace(/_/g, '/'));
}
}
setSelectedEmail({
id: email.id,
threadId: email.threadId,
subject,
from,
to,
date,
body,
});
} catch (err) {
logger.error('Error loading email detail:', err);
setError('Failed to load email details. Please try again.');
} finally {
setLoading(false);
}
};
const sendReply = async () => {
if (!accessToken || !selectedEmail || !replyText.trim()) return;
setSendingReply(true);
setError(null);
try {
// Create the email message
const to = selectedEmail.from.match(/<(.+)>/)?.[1] || selectedEmail.from;
const subject = selectedEmail.subject.startsWith('Re:')
? selectedEmail.subject
: `Re: ${selectedEmail.subject}`;
const messageParts = [
`To: ${to}`,
`Subject: ${subject}`,
`In-Reply-To: ${selectedEmail.id}`,
`References: ${selectedEmail.id}`,
'',
replyText,
];
const message = messageParts.join('\n');
const encodedMessage = btoa(message)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/send`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
raw: encodedMessage,
threadId: selectedEmail.threadId,
}),
}
);
if (!response.ok) {
throw new Error(`Gmail API error: ${response.statusText}`);
}
alert('Reply sent successfully!');
setReplyText('');
} catch (err) {
logger.error('Error sending reply:', err);
setError('Failed to send reply. Please try again.');
} finally {
setSendingReply(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<button
onClick={() => router.back()}
className="text-blue-600 hover:text-blue-800 mb-4"
>
Back to Integrations
</button>
<h1 className="text-2xl font-bold">Gmail Search</h1>
</div>
{/* Search Section */}
<div className="mb-6">
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && searchEmails()}
placeholder="Search emails (e.g., from:user@example.com, subject:meeting)"
className="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!accessToken || loading}
/>
<button
onClick={searchEmails}
disabled={!accessToken || loading || !searchQuery.trim()}
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-md">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Email List */}
<div className="bg-card border border-border rounded-lg">
<div className="p-4 border-b border-border">
<h2 className="text-lg font-semibold">Search Results</h2>
</div>
<div className="divide-y divide-border max-h-[600px] overflow-y-auto">
{emails.length === 0 && !loading && (
<div className="p-8 text-center text-muted-foreground">
{searchQuery ? 'No emails found' : 'Enter a search query to find emails'}
</div>
)}
{emails.map((email) => (
<div
key={email.id}
onClick={() => loadEmailDetail(email.id)}
className={`p-4 cursor-pointer hover:bg-muted/50 ${
selectedEmail?.id === email.id ? 'bg-accent' : ''
}`}
>
<div className="font-medium text-sm mb-1">{email.subject}</div>
<div className="text-xs text-muted-foreground mb-1">{email.from}</div>
<div className="text-xs text-muted-foreground">{email.snippet}</div>
<div className="text-xs text-muted-foreground/70 mt-1">{email.date}</div>
</div>
))}
</div>
</div>
{/* Email Detail and Reply */}
<div className="bg-card border border-border rounded-lg">
<div className="p-4 border-b border-border">
<h2 className="text-lg font-semibold">Email Details</h2>
</div>
{selectedEmail ? (
<div className="p-4">
<div className="mb-4 pb-4 border-b border-border">
<div className="mb-2">
<span className="font-medium">Subject:</span> {selectedEmail.subject}
</div>
<div className="mb-2 text-sm">
<span className="font-medium">From:</span> {selectedEmail.from}
</div>
<div className="mb-2 text-sm">
<span className="font-medium">To:</span> {selectedEmail.to}
</div>
<div className="text-sm text-muted-foreground">
<span className="font-medium">Date:</span> {selectedEmail.date}
</div>
</div>
<div className="mb-4 p-4 bg-muted rounded max-h-[200px] overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm">{selectedEmail.body}</pre>
</div>
<div className="border-t border-border pt-4">
<h3 className="font-medium mb-2">Reply</h3>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Type your reply here..."
className="w-full px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring mb-2 bg-background"
rows={6}
disabled={sendingReply}
/>
<button
onClick={sendReply}
disabled={sendingReply || !replyText.trim()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
>
{sendingReply ? 'Sending...' : 'Send Reply'}
</button>
</div>
</div>
) : (
<div className="p-8 text-center text-muted-foreground">
Select an email to view details and reply
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,174 +0,0 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { getIntegrationsApiV1IntegrationGet } from "@/client/sdk.gen";
import type { IntegrationResponse } from '@/client/types.gen';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import CreateIntegrationButton from "./CreateIntegrationButton";
function IntegrationsLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-muted rounded"></div>
<div className="h-10 w-32 bg-muted rounded"></div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full bg-card border border-border">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Integration ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Channel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Created At
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-32 bg-muted rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-muted rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-muted rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-muted rounded"></div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default function IntegrationsPage() {
const { user, loading: authLoading } = useAuth();
const hasFetched = useRef(false);
const [integrations, setIntegrations] = useState<IntegrationResponse[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (authLoading || !user || hasFetched.current) return;
hasFetched.current = true;
const fetchIntegrations = async () => {
try {
const response = await getIntegrationsApiV1IntegrationGet({});
const integrationData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : [];
const sorted = [...integrationData].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
setIntegrations(sorted);
} catch (err) {
logger.error(`Error fetching integrations: ${err}`);
setError('Failed to load Integrations. Please Try Again Later.');
}
};
fetchIntegrations();
}, [authLoading, user]);
if (authLoading || (integrations === null && !error)) {
return <IntegrationsLoading />;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Your Integrations</h1>
<CreateIntegrationButton />
</div>
{error ? (
<div className="text-red-500 text-center py-8">{error}</div>
) : !integrations || integrations.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No integrations found. Create your first integration to get started.
</div>
) : (
<div className="space-y-6">
<div className="overflow-x-auto">
<table className="min-w-full bg-card border border-border">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Channel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Created At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{integrations.map((integration) => (
<tr key={integration.id} className="hover:bg-muted/50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{integration.provider}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{integration.action}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{new Date(integration.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{integration.provider === 'google-mail' && (
<Link
href={`/integrations/${integration.id}/gmail`}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Search
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -181,8 +181,7 @@ export default function TelephonyConfigurationsPage() {
? "1 Telnyx configuration is"
: `${telnyxMissingWebhookPublicKeyCount} Telnyx configurations are`}{" "}
missing a webhook public key. Without it, Telnyx call status
updates and inbound calls will be rejected starting{" "}
<span className="font-medium">15 May 2026</span>. Copy your
updates and inbound calls are being rejected. Copy your
public key from{" "}
<span className="whitespace-nowrap">
Mission Control Portal Keys &amp; Credentials Public Key

View file

@ -9,16 +9,25 @@ import {
listRecordingsApiV1WorkflowRecordingsGet,
updateToolApiV1ToolsToolUuidPut,
} from "@/client/sdk.gen";
import type { RecordingResponseSchema, ToolResponse, TransferCallConfig as APITransferCallConfig } from "@/client/types.gen";
import type { EndCallConfig } from "@/client/types.gen";
import type {
EndCallConfig,
HttpApiToolDefinition,
RecordingResponseSchema,
ToolResponse,
TransferCallConfig as APITransferCallConfig,
UpdateToolRequest,
} from "@/client/types.gen";
import {
CredentialSelector,
type HttpMethod,
type KeyValueItem,
type ParameterType,
type PresetToolParameter,
type ToolParameter,
validateUrl,
} from "@/components/http";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -26,35 +35,33 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useAuth } from "@/lib/auth";
import {
createMcpDefinition,
DEFAULT_END_CALL_REASON_DESCRIPTION,
type EndCallMessageType,
getCategoryConfig,
getToolTypeLabel,
MCP_URL_PATTERN,
renderToolIcon,
type ToolCategory,
} from "../config";
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
// Extended HttpApiConfig with parameters (until client types are regenerated)
interface HttpApiConfigWithParams {
method?: string;
url?: string;
headers?: Record<string, string>;
credential_uuid?: string;
parameters?: ToolParameter[];
preset_parameters?: Array<{
name?: string;
type?: PresetToolParameter["type"];
value_template?: string;
required?: boolean;
}>;
timeout_ms?: number;
customMessage?: string;
function normalizeParameterType(value: string | null | undefined): ParameterType {
switch (value) {
case "number":
case "boolean":
return value;
default:
return "string";
}
}
export default function ToolDetailPage() {
@ -108,6 +115,11 @@ export default function ToolDetailPage() {
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
const [customMessageRecordingId, setCustomMessageRecordingId] = useState("");
// MCP form state
const [mcpUrl, setMcpUrl] = useState("");
const [mcpCredentialUuid, setMcpCredentialUuid] = useState("");
const [mcpToolsFilter, setMcpToolsFilter] = useState("");
// Org-level recordings for audio dropdowns
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
@ -155,8 +167,7 @@ export default function ToolDetailPage() {
if (config) {
setEndCallMessageType(config.messageType || "none");
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setAudioRecordingId((config as any).audioRecordingId || "");
setAudioRecordingId(config.audioRecordingId || "");
setEndCallReason(config.endCallReason ?? false);
setEndCallReasonDescription(config.endCallReasonDescription || "");
} else {
@ -173,8 +184,7 @@ export default function ToolDetailPage() {
setTransferDestination(config.destination || "");
setTransferMessageType(config.messageType || "none");
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTransferAudioRecordingId((config as any).audioRecordingId || "");
setTransferAudioRecordingId(config.audioRecordingId || "");
setTransferTimeout(config.timeout ?? 30);
} else {
setTransferDestination("");
@ -183,19 +193,35 @@ export default function ToolDetailPage() {
setTransferAudioRecordingId("");
setTransferTimeout(30);
}
} else if (tool.category === "mcp") {
// Populate MCP specific fields
const config = tool.definition?.config as
| { url?: string; credential_uuid?: string | null; tools_filter?: string[] }
| undefined;
if (config) {
setMcpUrl(config.url || "");
setMcpCredentialUuid(config.credential_uuid || "");
setMcpToolsFilter(
Array.isArray(config.tools_filter)
? config.tools_filter.join(", ")
: ""
);
} else {
setMcpUrl("");
setMcpCredentialUuid("");
setMcpToolsFilter("");
}
} else {
// Populate HTTP API specific fields
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
const config = tool.definition?.config as HttpApiToolDefinition["config"] | undefined;
if (config) {
setHttpMethod((config.method as HttpMethod) || "POST");
setUrl(config.url || "");
setCredentialUuid(config.credential_uuid || "");
setTimeoutMs(config.timeout_ms || 5000);
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setCustomMessageType((config as any).customMessageType || "text");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setCustomMessageRecordingId((config as any).customMessageRecordingId || "");
setCustomMessageType(config.customMessageType || "text");
setCustomMessageRecordingId(config.customMessageRecordingId || "");
// Convert headers object to array
if (config.headers) {
@ -212,9 +238,9 @@ export default function ToolDetailPage() {
// Load parameters
if (config.parameters && Array.isArray(config.parameters)) {
setParameters(
config.parameters.map((p: ToolParameter) => ({
config.parameters.map((p) => ({
name: p.name || "",
type: p.type || "string",
type: normalizeParameterType(p.type),
description: p.description || "",
required: p.required ?? true,
}))
@ -227,7 +253,7 @@ export default function ToolDetailPage() {
setPresetParameters(
config.preset_parameters.map((p) => ({
name: p.name || "",
type: p.type || "string",
type: normalizeParameterType(p.type),
valueTemplate: p.value_template || "",
required: p.required ?? true,
}))
@ -275,6 +301,16 @@ export default function ToolDetailPage() {
setError("Please enter a valid phone number (E.164 format) or SIP endpoint (e.g., PJSIP/1234)");
return;
}
} else if (tool.category === "mcp") {
// Validate MCP server URL (must be http(s))
if (!mcpUrl.trim()) {
setError("Please enter the MCP server URL");
return;
}
if (!MCP_URL_PATTERN.test(mcpUrl.trim())) {
setError("MCP server URL must start with http:// or https://");
return;
}
} else if (tool.category !== "end_call") {
// Validate URL for HTTP API tools
const urlValidation = validateUrl(url);
@ -305,7 +341,7 @@ export default function ToolDetailPage() {
setSaveSuccess(false);
const accessToken = await getAccessToken();
let requestBody;
let requestBody: UpdateToolRequest;
if (tool.category === "calculator") {
// Built-in tool - only name/description, no config
@ -351,6 +387,12 @@ export default function ToolDetailPage() {
},
},
};
} else if (tool.category === "mcp") {
requestBody = {
name,
description: description || undefined,
definition: createMcpDefinition(mcpUrl, mcpCredentialUuid, mcpToolsFilter),
};
} else {
// Build HTTP API request body
const headersObject: Record<string, string> = {};
@ -399,8 +441,7 @@ export default function ToolDetailPage() {
const response = await updateToolApiV1ToolsToolUuidPut({
path: { tool_uuid: toolUuid },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: requestBody as any,
body: requestBody,
headers: {
Authorization: `Bearer ${accessToken}`,
},
@ -510,6 +551,7 @@ const data = await response.json();`;
const isEndCallTool = tool.category === "end_call";
const isTransferCallTool = tool.category === "transfer_call";
const isBuiltinTool = tool.category === "calculator";
const isMcpTool = tool.category === "mcp";
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
@ -545,7 +587,7 @@ const data = await response.json();`;
</div>
</div>
<div className="flex items-center gap-2">
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && (
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && !isMcpTool && (
<Button
variant="outline"
onClick={() => setShowCodeDialog(true)}
@ -613,6 +655,79 @@ const data = await response.json();`;
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
/>
) : isMcpTool ? (
<Card>
<CardHeader>
<CardTitle>MCP Server Configuration</CardTitle>
<CardDescription>
Configure the MCP server endpoint. Its tools become available to the agent.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="mcp-name">Tool Name</Label>
<Input
id="mcp-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Customer MCP Server"
/>
</div>
<div className="space-y-2">
<Label htmlFor="mcp-description">Description</Label>
<p className="text-xs text-muted-foreground">
Provide a description which makes it easy for LLM to understand what this tool does
</p>
<Textarea
id="mcp-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this MCP server provide?"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mcp-url">MCP Server URL</Label>
<Input
id="mcp-url"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder="https://your-mcp-server.example.com/mcp"
/>
</div>
<div className="space-y-2">
<Label>Transport</Label>
<Input
value="Streamable HTTP"
disabled
readOnly
/>
</div>
<CredentialSelector
value={mcpCredentialUuid}
onChange={setMcpCredentialUuid}
label="Credential (Optional)"
description="Select a credential for authenticating with the MCP server, or leave empty for no auth."
/>
<div className="space-y-2">
<Label htmlFor="mcp-tools-filter">Tools Filter (Optional)</Label>
<Input
id="mcp-tools-filter"
value={mcpToolsFilter}
onChange={(e) => setMcpToolsFilter(e.target.value)}
placeholder="e.g., tool_one, tool_two"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of tool names to allow. Leave empty to expose all tools from the server.
</p>
</div>
</CardContent>
</Card>
) : (
<HttpApiToolConfig
name={name}

View file

@ -8,11 +8,12 @@ import type {
EndCallConfig,
EndCallToolDefinition,
HttpApiToolDefinition,
McpToolDefinition,
TransferCallConfig,
TransferCallToolDefinition,
} from "@/client/types.gen";
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration";
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
export type EndCallMessageType = "none" | "custom" | "audio";
@ -75,6 +76,14 @@ export const TOOL_CATEGORIES: ToolCategoryConfig[] = [
description: "Perform arithmetic calculations (supports +, -, *, /, **, %, and parentheses)",
},
},
{
value: "mcp",
label: "MCP Server",
description: "Connect a customer MCP server; its tools become available to the agent",
icon: Puzzle,
iconName: "puzzle",
iconColor: "#8B5CF6",
},
{
value: "native",
label: "Native (Coming Soon)",
@ -128,6 +137,8 @@ export function getToolTypeLabel(category: string): string {
return "Native Tool";
case "integration":
return "Integration Tool";
case "mcp":
return "MCP Server Tool";
default:
return "Tool";
}
@ -149,7 +160,12 @@ export const DEFAULT_TRANSFER_CALL_CONFIG: TransferCallConfig = {
timeout: 30,
};
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition | TransferCallToolDefinition | CalculatorToolDefinition;
export type ToolDefinition =
| HttpApiToolDefinition
| EndCallToolDefinition
| TransferCallToolDefinition
| CalculatorToolDefinition
| McpToolDefinition;
export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefinition {
return {
@ -185,6 +201,28 @@ export function createCalculatorDefinition(): CalculatorToolDefinition {
};
}
export const MCP_URL_PATTERN = /^https?:\/\//i;
export function createMcpDefinition(
url: string,
credentialUuid: string,
toolsFilterCsv: string,
): McpToolDefinition {
return {
schema_version: 1,
type: "mcp" as const,
config: {
transport: "streamable_http" as const,
url: url.trim(),
credential_uuid: credentialUuid || null,
tools_filter: toolsFilterCsv
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
},
};
}
export function createToolDefinition(category: ToolCategory): ToolDefinition {
switch (category) {
case "end_call":

View file

@ -10,7 +10,8 @@ import {
listToolsApiV1ToolsGet,
unarchiveToolApiV1ToolsToolUuidUnarchivePost,
} from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import type { CreateToolRequest, ToolResponse } from "@/client/types.gen";
import { CredentialSelector } from "@/components/http";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -41,8 +42,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/lib/auth";
import {
createMcpDefinition,
createToolDefinition,
getCategoryConfig,
MCP_URL_PATTERN,
renderToolIcon,
TOOL_CATEGORIES,
type ToolCategory,
@ -63,6 +66,11 @@ export default function ToolsPage() {
const [error, setError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
// MCP-specific create dialog state
const [mcpUrl, setMcpUrl] = useState("");
const [mcpCredentialUuid, setMcpCredentialUuid] = useState("");
const [mcpToolsFilter, setMcpToolsFilter] = useState("");
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
@ -108,21 +116,38 @@ export default function ToolsPage() {
return;
}
if (newToolCategory === "mcp" && !mcpUrl.trim()) {
setCreateError("Please enter the MCP server URL");
return;
}
if (newToolCategory === "mcp" && !MCP_URL_PATTERN.test(mcpUrl.trim())) {
setCreateError("MCP server URL must start with http:// or https://");
return;
}
try {
setIsCreating(true);
setCreateError(null);
const accessToken = await getAccessToken();
const categoryConfig = getCategoryConfig(newToolCategory);
const definition = newToolCategory === "mcp"
? createMcpDefinition(mcpUrl, mcpCredentialUuid, mcpToolsFilter)
: createToolDefinition(newToolCategory);
const requestBody: CreateToolRequest = {
name: newToolName,
description: newToolDescription || undefined,
category: newToolCategory,
icon: categoryConfig?.iconName || "globe",
icon_color: categoryConfig?.iconColor || "#3B82F6",
definition,
};
const response = await createToolApiV1ToolsPost({
body: {
name: newToolName,
description: newToolDescription || undefined,
category: newToolCategory,
icon: categoryConfig?.iconName || "globe",
icon_color: categoryConfig?.iconColor || "#3B82F6",
definition: createToolDefinition(newToolCategory),
},
body: requestBody,
headers: {
Authorization: `Bearer ${accessToken}`,
},
@ -139,6 +164,9 @@ export default function ToolsPage() {
setNewToolName("");
setNewToolDescription("");
setNewToolCategory("http_api");
setMcpUrl("");
setMcpCredentialUuid("");
setMcpToolsFilter("");
// Navigate to the new tool's detail page
router.push(`/tools/${response.data.tool_uuid}`);
}
@ -233,6 +261,8 @@ export default function ToolsPage() {
return <Badge variant="secondary">Native</Badge>;
case "integration":
return <Badge variant="outline">Integration</Badge>;
case "mcp":
return <Badge variant="outline">MCP</Badge>;
default:
return <Badge variant="outline">{category}</Badge>;
}
@ -465,7 +495,14 @@ export default function ToolsPage() {
{/* Create Tool Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
setIsCreateDialogOpen(open);
if (open) setCreateError(null);
if (open) {
setCreateError(null);
} else {
// Reset MCP fields when dialog is closed without creating
setMcpUrl("");
setMcpCredentialUuid("");
setMcpToolsFilter("");
}
}}>
<DialogContent>
<DialogHeader>
@ -482,6 +519,7 @@ export default function ToolsPage() {
onValueChange={(v) => {
const category = v as ToolCategory;
setNewToolCategory(category);
setCreateError(null);
const categoryConfig = getCategoryConfig(category);
if (categoryConfig?.autoFill) {
setNewToolName(categoryConfig.autoFill.name);
@ -532,6 +570,46 @@ export default function ToolsPage() {
placeholder="What does this tool do?"
/>
</div>
{newToolCategory === "mcp" && (
<>
<div className="grid gap-2">
<Label htmlFor="mcp-url">MCP Server URL</Label>
<Input
id="mcp-url"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder="https://your-mcp-server.example.com/mcp"
/>
</div>
<div className="grid gap-2">
<Label>Transport</Label>
<Input
value="Streamable HTTP"
disabled
readOnly
/>
</div>
<CredentialSelector
value={mcpCredentialUuid}
onChange={setMcpCredentialUuid}
label="Credential (Optional)"
description="Select a credential for authenticating with the MCP server, or leave empty for no auth."
/>
<div className="grid gap-2">
<Label htmlFor="mcp-tools-filter">Tools Filter (Optional)</Label>
<Input
id="mcp-tools-filter"
value={mcpToolsFilter}
onChange={(e) => setMcpToolsFilter(e.target.value)}
placeholder="e.g., tool_one, tool_two"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of tool names to allow. Leave empty to expose all tools from the server.
</p>
</div>
</>
)}
</div>
{createError && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm">

View file

@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from '@/client/types.gen';
import { useNodeSpecs } from "@/components/flow/renderer";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent } from '@/components/ui/sheet';
@ -29,14 +30,6 @@ import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
import { layoutNodes } from './utils/layoutNodes';
// Single generic component for every node type. The spec catalog
// (`/api/v1/node-types`) drives form rendering, canvas preview, handles,
// and defaults. Adding a new node type means adding a Python NodeSpec —
// no React changes required.
const nodeTypes = Object.fromEntries(
Object.values(NodeType).map((t) => [t, GenericNode]),
);
const edgeTypes = {
custom: CustomEdge,
};
@ -65,6 +58,7 @@ interface RenderWorkflowProps {
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
const router = useRouter();
const { specs } = useNodeSpecs();
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isTesterRailOpen, setIsTesterRailOpen] = useState(true);
@ -111,6 +105,22 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
user,
});
// Single generic component for every node type. Seed with core node types
// so the initial render is stable before specs load, then merge in any
// spec-defined or already-present node types so plugin integrations like
// Tuner render without extra React registrations.
const nodeTypes = useMemo(() => {
const typeNames = new Set<string>([
...Object.values(NodeType),
...specs.map((spec) => spec.name),
...nodes.map((node) => node.type),
...(initialFlow?.nodes ?? []).map((node) => node.type),
]);
return Object.fromEntries(
Array.from(typeNames).map((typeName) => [typeName, GenericNode]),
);
}, [initialFlow?.nodes, nodes, specs]);
// Derive hasDraft from the current version status
const hasDraft = currentVersionStatus === "draft";
@ -350,14 +360,33 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
await saveWorkflowConfigurations(workflowConfigurations, newName);
}, [saveWorkflowConfigurations, workflowConfigurations]);
const updateTool = useCallback(
(toolUuid: string, updater: (tool: ToolResponse) => ToolResponse) => {
setTools((prev) =>
prev?.map((tool) =>
tool.tool_uuid === toolUuid ? updater(tool) : tool,
),
);
},
[],
);
// Memoize the context value to prevent unnecessary re-renders
const workflowContextValue = useMemo(() => ({
saveWorkflow: guardedSaveWorkflow,
documents,
tools,
updateTool,
recordings,
readOnly: isViewingHistoricalVersion,
}), [guardedSaveWorkflow, documents, tools, recordings, isViewingHistoricalVersion]);
}), [
guardedSaveWorkflow,
documents,
tools,
updateTool,
recordings,
isViewingHistoricalVersion,
]);
return (
<WorkflowProvider value={workflowContextValue}>

View file

@ -7,6 +7,10 @@ interface WorkflowContextType {
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
documents?: DocumentResponseSchema[];
tools?: ToolResponse[];
updateTool?: (
toolUuid: string,
updater: (tool: ToolResponse) => ToolResponse,
) => void;
recordings?: RecordingResponseSchema[];
readOnly?: boolean;
}

View file

@ -9,6 +9,23 @@ const NODE_HEIGHT = 120;
const VERTICAL_SPACING = 150; // Vertical spacing between stacked nodes
const SECTION_HORIZONTAL_GAP = 500; // Horizontal gap between sections
const WORKFLOW_NODE_TYPES = new Set([
NodeType.START_CALL,
NodeType.AGENT_NODE,
NodeType.END_CALL,
]);
function isRightRailNode(type: string): boolean {
if (type === NodeType.WEBHOOK || type === NodeType.QA) {
return true;
}
return (
!WORKFLOW_NODE_TYPES.has(type as NodeType) &&
type !== NodeType.TRIGGER &&
type !== NodeType.GLOBAL_NODE
);
}
export const layoutNodes = (
nodes: FlowNode[],
edges: FlowEdge[],
@ -17,14 +34,9 @@ export const layoutNodes = (
) => {
// Separate nodes by type
const triggerNodes = nodes.filter(n => n.type === NodeType.TRIGGER);
const webhookNodes = nodes.filter(n => n.type === NodeType.WEBHOOK);
const qaNodes = nodes.filter(n => n.type === NodeType.QA);
const globalNodes = nodes.filter(n => n.type === NodeType.GLOBAL_NODE);
const workflowNodes = nodes.filter(n =>
n.type === NodeType.START_CALL ||
n.type === NodeType.AGENT_NODE ||
n.type === NodeType.END_CALL
);
const workflowNodes = nodes.filter(n => WORKFLOW_NODE_TYPES.has(n.type as NodeType));
const rightSideNodes = nodes.filter(n => isRightRailNode(n.type));
// If no workflow nodes, just return original nodes
if (workflowNodes.length === 0) {
@ -145,30 +157,19 @@ export const layoutNodes = (
};
});
// Position webhook nodes to the right of the workflow
const webhookNodesX = workflowMaxX + SECTION_HORIZONTAL_GAP;
const positionedWebhookNodes = webhookNodes.map((node, index) => {
const totalHeight = webhookNodes.length * NODE_HEIGHT + (webhookNodes.length - 1) * VERTICAL_SPACING;
const startY = workflowCenterY - totalHeight / 2;
return {
...node,
position: {
x: webhookNodesX,
y: startY + index * (NODE_HEIGHT + VERTICAL_SPACING)
}
};
});
// Position QA nodes below webhook nodes on the right side
const qaStartY = webhookNodes.length > 0
? workflowCenterY - (webhookNodes.length * NODE_HEIGHT + (webhookNodes.length - 1) * VERTICAL_SPACING) / 2
+ webhookNodes.length * (NODE_HEIGHT + VERTICAL_SPACING) + VERTICAL_SPACING
const rightSideStartY = rightSideNodes.length > 0
? workflowCenterY - (
rightSideNodes.length * NODE_HEIGHT +
(rightSideNodes.length - 1) * VERTICAL_SPACING
) / 2
: workflowCenterY;
const positionedQaNodes = qaNodes.map((node, index) => ({
const positionedRightSideNodes = rightSideNodes.map((node, index) => ({
...node,
position: {
x: webhookNodesX,
y: qaStartY + index * (NODE_HEIGHT + VERTICAL_SPACING)
y: rightSideStartY + index * (NODE_HEIGHT + VERTICAL_SPACING)
}
}));
@ -177,8 +178,7 @@ export const layoutNodes = (
...positionedTriggerNodes,
...positionedGlobalNodes,
...positionedWorkflowNodes,
...positionedWebhookNodes,
...positionedQaNodes
...positionedRightSideNodes,
];
// Create a map for quick lookup