This commit is contained in:
XI 2026-05-23 13:09:36 +01:00
commit 6817117f2b
385 changed files with 27923 additions and 12381 deletions

View file

@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
const GHCR_IMAGES = ["dograh-hq/dograh-ui", "dograh-hq/dograh-api"] as const;
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const REVALIDATE_SECONDS = 60 * 60;
type Semver = [number, number, number];
function parseSemver(tag: string): Semver | null {
const m = tag.match(SEMVER_RE);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
function compareSemver(a: Semver, b: Semver): number {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
async function fetchLatestTag(image: string): Promise<string | null> {
const tokenRes = await fetch(
`https://ghcr.io/token?scope=repository:${image}:pull&service=ghcr.io`,
{ next: { revalidate: REVALIDATE_SECONDS } },
);
if (!tokenRes.ok) return null;
const { token } = (await tokenRes.json()) as { token?: string };
if (!token) return null;
const tagsRes = await fetch(`https://ghcr.io/v2/${image}/tags/list`, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: REVALIDATE_SECONDS },
});
if (!tagsRes.ok) return null;
const { tags } = (await tagsRes.json()) as { tags?: string[] };
let latest: { tag: string; parsed: Semver } | null = null;
for (const tag of tags ?? []) {
const parsed = parseSemver(tag);
if (!parsed) continue;
if (!latest || compareSemver(parsed, latest.parsed) > 0) {
latest = { tag, parsed };
}
}
return latest?.tag ?? null;
}
export async function GET() {
try {
const results = await Promise.all(GHCR_IMAGES.map(fetchLatestTag));
// Only advertise an update once every image has published a tag at that
// version — otherwise we'd nudge users to upgrade before the matching
// container actually exists.
let minLatest: { tag: string; parsed: Semver } | null = null;
for (const tag of results) {
if (!tag) return NextResponse.json({ latest: null }, { status: 200 });
const parsed = parseSemver(tag);
if (!parsed) return NextResponse.json({ latest: null }, { status: 200 });
if (!minLatest || compareSemver(parsed, minLatest.parsed) < 0) {
minLatest = { tag, parsed };
}
}
return NextResponse.json(
{ latest: minLatest?.tag ?? null },
{
headers: {
"Cache-Control": `public, max-age=${REVALIDATE_SECONDS}, s-maxage=${REVALIDATE_SECONDS}`,
},
},
);
} catch {
return NextResponse.json({ latest: null }, { status: 200 });
}
}

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

@ -135,22 +135,3 @@
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
/* Smaller Chatwoot bubble on workflow run pages */
body.chatwoot-compact .woot--bubble-holder {
transform: scale(0.7) !important;
transform-origin: bottom right !important;
right: 4px !important;
bottom: 4px !important;
}
body.chatwoot-compact .woot--bubble-holder .woot-widget-bubble {
width: 48px !important;
height: 48px !important;
}
body.chatwoot-compact #chatwoot_live_chat_widget {
bottom: 60px !important;
right: 4px !important;
}

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

@ -1,21 +0,0 @@
import React, { ReactNode } from 'react'
import AppLayout from '@/components/layout/AppLayout'
interface LoopTalkLayoutProps {
children: ReactNode,
headerActions?: ReactNode,
backButton?: ReactNode,
}
const LoopTalkLayout: React.FC<LoopTalkLayoutProps> = ({ children, headerActions }) => {
// backButton is kept in interface for backward compatibility
// but not used with the new sidebar layout
return (
<AppLayout headerActions={headerActions}>
{children}
</AppLayout>
)
}
export default LoopTalkLayout

View file

@ -1,127 +0,0 @@
'use client';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet } from '@/client/sdk.gen';
import type { TestSessionResponse } from '@/client/types.gen';
import { ConversationsList } from '@/components/looptalk/ConversationsList';
import { LiveAudioPlayer } from '@/components/looptalk/LiveAudioPlayer';
import { TestSessionControls } from '@/components/looptalk/TestSessionControls';
import { TestSessionDetails } from '@/components/looptalk/TestSessionDetails';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import LoopTalkLayout from "../LoopTalkLayout";
function TestSessionLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-4">
<div className="h-32 bg-muted rounded-lg animate-pulse"></div>
<div className="h-20 bg-muted rounded-lg animate-pulse"></div>
<div className="h-64 bg-muted rounded-lg animate-pulse"></div>
</div>
</div>
);
}
function TestSessionPageContent() {
const params = useParams();
const testSessionId = parseInt(params.id as string);
const { user, loading: authLoading } = useAuth();
const hasFetched = useRef(false);
const [testSession, setTestSession] = useState<TestSessionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (authLoading || !user || hasFetched.current) return;
hasFetched.current = true;
const fetchTestSession = async () => {
try {
const response = await getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet({
path: {
test_session_id: testSessionId
},
});
if (!response.data) {
setError('Test session not found');
return;
}
setTestSession(response.data);
} catch (err) {
logger.error(`Error fetching test session: ${err}`);
setError('Failed to load test session');
}
};
fetchTestSession();
}, [authLoading, user, testSessionId]);
if (authLoading || (testSession === null && !error)) {
return <TestSessionLoading />;
}
if (error || !testSession) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-red-500 text-center py-8">
{error || 'Test session not found'}
</div>
</div>
);
}
const sessionForUI = {
id: testSession.id,
name: testSession.name,
description: '',
test_type: testSession.test_index !== null ? 'load_test' : 'single',
status: testSession.status,
actor_workflow_name: `Workflow ${testSession.actor_workflow_id}`,
adversary_workflow_name: `Workflow ${testSession.adversary_workflow_id}`,
created_at: testSession.created_at,
updated_at: testSession.created_at,
test_metadata: testSession.config
};
return (
<div className="container mx-auto px-4 py-8">
<TestSessionDetails session={sessionForUI} />
<TestSessionControls session={sessionForUI} />
<div className="mt-6">
<LiveAudioPlayer
testSessionId={testSessionId}
sessionStatus={testSession.status as 'pending' | 'running' | 'completed' | 'failed'}
autoStart={true}
/>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold mb-4">Conversations</h2>
<ConversationsList testSessionId={testSessionId} />
</div>
</div>
);
}
export default function TestSessionPage() {
const backButton = (
<Link href="/looptalk">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Test Sessions
</Button>
</Link>
);
return (
<LoopTalkLayout backButton={backButton}>
<TestSessionPageContent />
</LoopTalkLayout>
);
}

View file

@ -1,40 +0,0 @@
"use client";
import { MessageSquare } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function LoopTalkPage() {
return (
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">LoopTalk</h1>
<p>Enable voice agents to talk to each other and create artificial datasets</p>
</div>
<Card>
<CardHeader>
<CardTitle>Coming Soon</CardTitle>
<CardDescription>
LoopTalk features are currently under development
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<MessageSquare className="w-16 h-16 mx-auto mb-6" />
<p className="text-lg mb-4">
We&apos;re building LoopTalk to enable voice agents to communicate with each other,
allowing you to generate artificial datasets for training and testing.
</p>
<p>
This powerful feature will help you create comprehensive test scenarios and improve your voice AI workflows.
</p>
<p className="mt-4">
Check back soon for updates!
</p>
</div>
</CardContent>
</Card>
</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

@ -11,6 +11,8 @@ import {
KeyValueEditor,
type KeyValueItem,
ParameterEditor,
PresetParameterEditor,
type PresetToolParameter,
type ToolParameter,
UrlInput,
} from "@/components/http";
@ -35,6 +37,8 @@ export interface HttpApiToolConfigProps {
onHeadersChange: (headers: KeyValueItem[]) => void;
parameters: ToolParameter[];
onParametersChange: (parameters: ToolParameter[]) => void;
presetParameters: PresetToolParameter[];
onPresetParametersChange: (parameters: PresetToolParameter[]) => void;
timeoutMs: number;
onTimeoutMsChange: (timeout: number) => void;
customMessage: string;
@ -61,6 +65,8 @@ export function HttpApiToolConfig({
onHeadersChange,
parameters,
onParametersChange,
presetParameters,
onPresetParametersChange,
timeoutMs,
onTimeoutMsChange,
customMessage,
@ -182,7 +188,7 @@ export function HttpApiToolConfig({
<TabsContent value="parameters" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Tool Parameters</Label>
<Label>LLM Parameters</Label>
<Label className="text-xs text-muted-foreground">
Define the parameters that the LLM will provide when calling this tool.
These will be sent as JSON body for POST/PUT/PATCH or as URL query params for GET/DELETE.
@ -193,6 +199,18 @@ export function HttpApiToolConfig({
/>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Preset Parameters</Label>
<Label className="text-xs text-muted-foreground">
Add values that Dograh should inject at runtime. These are not exposed to the LLM and can use
workflow templates like {`{{initial_context.phone_number}}`} or fixed literals.
</Label>
<PresetParameterEditor
parameters={presetParameters}
onChange={onPresetParametersChange}
/>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Custom Headers</Label>
<Label className="text-xs text-muted-foreground">

View file

@ -9,10 +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 HttpMethod, type KeyValueItem, type ToolParameter, validateUrl } from "@/components/http";
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,
@ -20,29 +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[];
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() {
@ -70,6 +89,7 @@ export default function ToolDetailPage() {
const [credentialUuid, setCredentialUuid] = useState("");
const [headers, setHeaders] = useState<KeyValueItem[]>([]);
const [parameters, setParameters] = useState<ToolParameter[]>([]);
const [presetParameters, setPresetParameters] = useState<PresetToolParameter[]>([]);
const [timeoutMs, setTimeoutMs] = useState(5000);
// End Call form state
@ -95,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[]>([]);
@ -142,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 {
@ -160,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("");
@ -170,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) {
@ -199,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,
}))
@ -209,6 +248,19 @@ export default function ToolDetailPage() {
} else {
setParameters([]);
}
if (config.preset_parameters && Array.isArray(config.preset_parameters)) {
setPresetParameters(
config.preset_parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
valueTemplate: p.value_template || "",
required: p.required ?? true,
}))
);
} else {
setPresetParameters([]);
}
}
}
};
@ -249,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);
@ -263,6 +325,14 @@ export default function ToolDetailPage() {
setError("All parameters must have a name");
return;
}
const invalidPresetParams = presetParameters.filter(
(p) => !p.name.trim() || !p.valueTemplate.trim()
);
if (invalidPresetParams.length > 0) {
setError("All preset parameters must have a name and a value");
return;
}
}
try {
@ -271,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
@ -317,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> = {};
@ -325,6 +401,9 @@ export default function ToolDetailPage() {
});
const validParameters = parameters.filter((p) => p.name.trim());
const validPresetParameters = presetParameters.filter(
(p) => p.name.trim() && p.valueTemplate.trim()
);
requestBody = {
name,
@ -342,6 +421,15 @@ export default function ToolDetailPage() {
: undefined,
parameters:
validParameters.length > 0 ? validParameters : undefined,
preset_parameters:
validPresetParameters.length > 0
? validPresetParameters.map((p) => ({
name: p.name,
type: p.type,
value_template: p.valueTemplate,
required: p.required,
}))
: undefined,
timeout_ms: timeoutMs,
customMessage: customMessageType === 'text' ? (customMessage || undefined) : undefined,
customMessageType,
@ -353,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}`,
},
@ -394,8 +481,20 @@ export default function ToolDetailPage() {
exampleBody[p.name] = `<${p.name}>`;
}
});
presetParameters.forEach((p) => {
if (p.type === "number") {
exampleBody[p.name] = p.valueTemplate || 0;
} else if (p.type === "boolean") {
exampleBody[p.name] = p.valueTemplate || true;
} else {
exampleBody[p.name] = p.valueTemplate || `<${p.name}>`;
}
});
const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && parameters.length > 0;
const hasBody =
httpMethod !== "GET" &&
httpMethod !== "DELETE" &&
(parameters.length > 0 || presetParameters.length > 0);
return `// ${tool.name}
// ${tool.description || "HTTP API Tool"}
@ -452,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 (
@ -487,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)}
@ -555,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}
@ -571,6 +744,8 @@ const data = await response.json();`;
onHeadersChange={setHeaders}
parameters={parameters}
onParametersChange={setParameters}
presetParameters={presetParameters}
onPresetParametersChange={setPresetParameters}
timeoutMs={timeoutMs}
onTimeoutMsChange={setTimeoutMs}
customMessage={customMessage}

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

@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { CallTypeCell } from '@/components/CallTypeCell';
import { DailyUsageTable } from '@/components/DailyUsageTable';
import { FilterBuilder } from '@/components/filters/FilterBuilder';
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
@ -534,13 +535,7 @@ export default function UsagePage() {
</TableCell>
<TableCell>{run.workflow_name || 'Unknown'}</TableCell>
<TableCell>
{run.call_type ? (
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
<CallTypeCell mode={run.mode} callType={run.call_type} />
</TableCell>
<TableCell className="text-sm">
{(run.call_type === 'inbound'
@ -635,4 +630,3 @@ export default function UsagePage() {
</div>
);
}

View file

@ -12,9 +12,12 @@ 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';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useOnboarding } from '@/context/OnboardingContext';
import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
@ -22,19 +25,13 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { GenericNode } from "../../../components/flow/nodes/GenericNode";
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
import type { WorkflowRuntimeNodeTransition } from './components/workflow-tester/types';
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
import { WorkflowTesterPanel } from './components/WorkflowTesterPanel';
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,
};
@ -45,6 +42,8 @@ interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
workflowUuid?: string;
initialTotalRuns?: number | null;
openTesterOnLoad?: boolean;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
@ -61,15 +60,33 @@ interface RenderWorkflowProps {
user: { id: string; email?: string };
}
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
function RenderWorkflow({
initialWorkflowName,
workflowId,
workflowUuid,
initialTotalRuns,
openTesterOnLoad = false,
initialFlow,
initialTemplateContextVariables,
initialWorkflowConfigurations,
initialVersionNumber,
initialVersionStatus,
user,
}: RenderWorkflowProps) {
const router = useRouter();
const { specs } = useNodeSpecs();
const { hasCompletedAction } = useOnboarding();
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isTesterRailOpen, setIsTesterRailOpen] = useState(true);
const [isTesterSheetOpen, setIsTesterSheetOpen] = useState(false);
const [isDesktopViewport, setIsDesktopViewport] = useState(false);
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [versionsLoadingMore, setVersionsLoadingMore] = useState(false);
const [versionsHasMore, setVersionsHasMore] = useState(false);
const [activeVersionId, setActiveVersionId] = useState<number | null>(null);
const hasAutoOpenedTester = useRef(false);
// Version info that updates immediately from the GET/save/publish responses.
const [currentVersionNumber, setCurrentVersionNumber] = useState<number | null>(initialVersionNumber ?? null);
const [currentVersionStatus, setCurrentVersionStatus] = useState<string | null>(initialVersionStatus ?? null);
@ -77,6 +94,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
const [activeRuntimeNodeId, setActiveRuntimeNodeId] = useState<string | null>(null);
const {
rfInstance,
@ -86,6 +104,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
workflowName,
isDirty,
workflowValidationErrors,
templateContextVariables,
setNodes,
setEdges,
setIsDirty,
@ -97,7 +116,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
onConnect,
onEdgesChange,
onNodesChange,
onRun,
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -107,6 +125,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";
@ -194,6 +228,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
return true;
}, [activeVersionId, versions, hasDraft]);
useEffect(() => {
if (!isViewingHistoricalVersion) {
return;
}
setActiveRuntimeNodeId(null);
}, [isViewingHistoricalVersion]);
// Return to the draft version, creating one from published if needed
const handleBackToDraft = useCallback(async () => {
const existingDraft = versions.find((v) => v.status === "draft");
@ -248,6 +289,50 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
return undefined;
}, [activeVersionId, versions, currentVersionNumber, currentVersionStatus]);
const testerDisabledReason = useMemo(() => {
if (isViewingHistoricalVersion) {
return "Return to the draft before starting a new test session.";
}
if (isDirty) {
return "Save the latest draft before testing so the session uses the workflow you are looking at.";
}
if (workflowValidationErrors.length > 0) {
return "Resolve the current validation errors before starting another test.";
}
return null;
}, [isDirty, isViewingHistoricalVersion, workflowValidationErrors.length]);
const handleOpenTester = useCallback(() => {
if (window.innerWidth >= 1280) {
setIsTesterRailOpen(true);
return;
}
setIsTesterSheetOpen(true);
}, []);
const shouldShowWebCallOnboarding = useMemo(() => {
return (initialTotalRuns ?? 0) === 0 && !hasCompletedAction('web_call_started');
}, [hasCompletedAction, initialTotalRuns]);
useEffect(() => {
const syncViewport = () => {
setIsDesktopViewport(window.innerWidth >= 1280);
};
syncViewport();
window.addEventListener('resize', syncViewport);
return () => window.removeEventListener('resize', syncViewport);
}, []);
useEffect(() => {
if (hasAutoOpenedTester.current || !openTesterOnLoad || !shouldShowWebCallOnboarding || testerDisabledReason) {
return;
}
handleOpenTester();
hasAutoOpenedTester.current = true;
}, [handleOpenTester, openTesterOnLoad, shouldShowWebCallOnboarding, testerDisabledReason]);
// Fetch documents, tools, and recordings once for the entire workflow
useEffect(() => {
const fetchData = async () => {
@ -291,6 +376,46 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
type: "custom"
}), []);
const displayNodes = useMemo(
() =>
nodes.map((node) =>
node.id === activeRuntimeNodeId
? {
...node,
data: {
...node.data,
runtime_active: true,
},
}
: node,
),
[activeRuntimeNodeId, nodes],
);
const handleRuntimeNodeTransition = useCallback(
(transition: WorkflowRuntimeNodeTransition) => {
const nodeId = transition.nodeId;
const instance = rfInstance.current;
if (!nodeId || !instance) {
return;
}
setActiveRuntimeNodeId(nodeId);
if (!instance.viewportInitialized) {
return;
}
void instance.fitView({
nodes: [{ id: nodeId }],
duration: 350,
padding: 0.45,
maxZoom: 0.9,
});
},
[rfInstance],
);
// Guard saveWorkflow so it's a no-op when viewing a historical version.
// This is the single safety net that covers every save path: header button,
// Cmd+S, node edit dialogs, stale doc/tool cleanup, etc.
@ -325,14 +450,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}>
@ -343,12 +487,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
isDirty={isDirty}
workflowValidationErrors={workflowValidationErrors}
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
workflowUuid={workflowUuid}
saveWorkflow={guardedSaveWorkflow}
user={user}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
onTestAgentClick={handleOpenTester}
onHistoryClick={handleOpenVersionPanel}
activeVersionLabel={activeVersionLabel}
isViewingHistoricalVersion={isViewingHistoricalVersion}
@ -359,158 +503,187 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
/>
{/* Workflow Canvas */}
<div className="flex-1 relative">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
setTimeout(() => {
instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
<div className="flex-1 min-h-0">
<div className="flex h-full min-w-0">
<div className="relative min-w-0 flex-1">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={displayNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
setTimeout(() => {
instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
{/* Top-right controls - vertical layout (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Panel position="top-right">
{/* Top-right controls - vertical layout (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Panel position="top-right">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}/settings`)}
className="bg-white shadow-sm hover:shadow-md"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Workflow settings</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
)}
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
<div className="absolute bottom-12 left-8 z-10 flex gap-2">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</p>
</TooltipContent>
</Tooltip>
{!isViewingHistoricalVersion && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}/settings`)}
className="bg-white shadow-sm hover:shadow-md"
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Settings className="h-4 w-4" />
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Workflow settings</p>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
</div>
)}
</TooltipProvider>
</Panel>
</div>
</div>
{isTesterRailOpen && (
<aside className="hidden h-full w-[420px] shrink-0 border-l border-border xl:block">
<WorkflowTesterPanel
workflowId={workflowId}
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isDesktopViewport}
onClose={() => setIsTesterRailOpen(false)}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</aside>
)}
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
<div className="absolute bottom-12 left-8 z-10 flex gap-2">
<TooltipProvider>
{/* Zoom In */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
{/* Zoom Out */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
{/* Fit View */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</p>
</TooltipContent>
</Tooltip>
{/* Tidy/Arrange Nodes (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<Sheet open={isTesterSheetOpen} onOpenChange={setIsTesterSheetOpen}>
<SheetContent side="right" className="w-full max-w-none p-0 sm:max-w-xl xl:hidden">
<WorkflowTesterPanel
workflowId={workflowId}
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isTesterSheetOpen}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</SheetContent>
</Sheet>
</div>
<AddNodePanel

View file

@ -1,9 +1,8 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react";
import { AlertCircle, ArrowLeft, Bot, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useRef, useState } from "react";
import { toast } from "sonner";
@ -28,20 +27,18 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useSidebar } from "@/components/ui/sidebar";
import { PostHogEvent } from "@/constants/posthog-events";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
interface WorkflowEditorHeaderProps {
workflowName: string;
isDirty: boolean;
workflowValidationErrors: WorkflowError[];
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
workflowUuid?: string;
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
onPhoneCallClick: () => void;
onTestAgentClick: () => void;
onHistoryClick: () => void;
activeVersionLabel?: string;
isViewingHistoricalVersion: boolean;
@ -57,8 +54,8 @@ export const WorkflowEditorHeader = ({
workflowValidationErrors,
rfInstance,
saveWorkflow,
onRun,
onPhoneCallClick,
onTestAgentClick,
onHistoryClick,
activeVersionLabel,
isViewingHistoricalVersion,
@ -301,7 +298,7 @@ export const WorkflowEditorHeader = ({
</div>
</div>
{/* Right section: Version + Unsaved indicator + Call button + Save button */}
{/* Right section: Version + status + tester/call actions + save */}
<div className="flex items-center gap-3">
{/* Read-only banner when viewing a historical version */}
{isViewingHistoricalVersion && (
@ -388,68 +385,6 @@ export const WorkflowEditorHeader = ({
</Popover>
)}
{/* Call button with dropdown (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
>
<Phone className="w-4 h-4" />
Call
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
<DropdownMenuItem
onClick={() => {
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
workflow_id: workflowId,
workflow_name: workflowName,
});
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
}}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Web Call
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// Delay opening dialog to next event cycle to allow DropdownMenu
// to clean up first, preventing pointer-events: none stuck on body
// See: https://github.com/radix-ui/primitives/issues/1241
setTimeout(onPhoneCallClick, 0);
}}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Phone Call
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Save button (only shown when editing the draft) */}
{!isViewingHistoricalVersion && (
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
{/* Publish button (only when on draft with no unsaved changes) */}
{!isViewingHistoricalVersion && hasDraft && (
<Button
@ -472,6 +407,45 @@ export const WorkflowEditorHeader = ({
</Button>
)}
{!isViewingHistoricalVersion && (
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
onClick={onPhoneCallClick}
>
<Phone className="w-4 h-4" />
Phone Call
</Button>
)}
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
onClick={onTestAgentClick}
>
<Bot className="w-4 h-4" />
Test Agent
</Button>
{/* Save button (only shown when editing the draft) */}
{!isViewingHistoricalVersion && (
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
{/* More options dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -0,0 +1,277 @@
"use client";
import { Loader2, MessageSquareText, Mic, Phone, RefreshCw, X } from "lucide-react";
import posthog from "posthog-js";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from "@/client/sdk.gen";
import { OnboardingTooltip } from "@/components/onboarding/OnboardingTooltip";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PostHogEvent } from "@/constants/posthog-events";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
import { useOnboarding } from "@/context/OnboardingContext";
import { useAuth } from "@/lib/auth";
import { cn, getRandomId } from "@/lib/utils";
import { AiSimulatorPlaceholder } from "./workflow-tester/AiSimulatorPlaceholder";
import { EmbeddedVoiceTester } from "./workflow-tester/EmbeddedVoiceTester";
import { ManualTextChatPanel } from "./workflow-tester/ManualTextChatPanel";
import { ChatModeToggle, DisabledNotice, EmptyState } from "./workflow-tester/shared";
import type { WorkflowRuntimeNodeTransition } from "./workflow-tester/types";
import { extractSdkErrorMessage, getErrorMessage } from "./workflow-tester/utils";
interface WorkflowTesterPanelProps {
workflowId: number;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
showWebCallOnboarding?: boolean;
isVisible?: boolean;
className?: string;
onClose?: () => void;
onRuntimeNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function WorkflowTesterPanel({
workflowId,
initialContextVariables,
disabled,
disabledReason,
showWebCallOnboarding = false,
isVisible = true,
className,
onClose,
onRuntimeNodeTransition,
}: WorkflowTesterPanelProps) {
const auth = useAuth();
const { hasSeenTooltip, markTooltipSeen, markActionCompleted } = useOnboarding();
const { isAuthenticated, loading: authLoading, getAccessToken } = auth;
const [accessToken, setAccessToken] = useState<string | null>(null);
const [activeMode, setActiveMode] = useState<"audio" | "text">("audio");
const [chatMode, setChatMode] = useState<"manual" | "simulated">("manual");
const [chatSessionKey, setChatSessionKey] = useState(0);
const [chatActive, setChatActive] = useState(false);
const [voiceRunId, setVoiceRunId] = useState<number | null>(null);
const [creatingVoiceRun, setCreatingVoiceRun] = useState(false);
const [tokenReady, setTokenReady] = useState(false);
const runTestButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
let ignore = false;
const hydrateAccessToken = async () => {
if (!isAuthenticated || authLoading) return;
try {
const token = await getAccessToken();
if (!ignore) {
setAccessToken(token);
}
} catch (error) {
if (!ignore) {
toast.error(getErrorMessage(error));
}
} finally {
if (!ignore) {
setTokenReady(true);
}
}
};
if (authLoading) {
return;
}
if (!isAuthenticated) {
setTokenReady(true);
return;
}
hydrateAccessToken();
return () => {
ignore = true;
};
}, [authLoading, getAccessToken, isAuthenticated]);
const createVoiceRun = useCallback(async () => {
if (!accessToken || disabled) return;
setCreatingVoiceRun(true);
try {
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: { workflow_id: workflowId },
body: {
mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC,
name: `WR-${getRandomId()}`,
},
});
if (response.error || !response.data?.id) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create browser test run"));
}
markActionCompleted("web_call_started");
markTooltipSeen("web_call");
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
workflow_id: workflowId,
workflow_run_id: response.data.id,
source: "workflow_editor",
});
setVoiceRunId(response.data.id);
setActiveMode("audio");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreatingVoiceRun(false);
}
}, [accessToken, disabled, markActionCompleted, markTooltipSeen, workflowId]);
const authUnavailableReason = tokenReady && !accessToken
? "Authentication is required before testing can start."
: null;
const effectiveDisabledReason = disabledReason ?? authUnavailableReason;
const testerBlocked = disabled || authUnavailableReason !== null;
const showRunTestTooltip =
showWebCallOnboarding &&
isVisible &&
activeMode === "audio" &&
!voiceRunId &&
tokenReady &&
!!accessToken &&
!testerBlocked &&
!hasSeenTooltip("web_call");
return (
<div className={cn("flex h-full min-h-0 flex-col bg-background", className)}>
<Tabs
value={activeMode}
onValueChange={(value) => setActiveMode(value as "audio" | "text")}
className="min-h-0 flex-1 gap-0"
>
<div className="border-b border-border/70 px-4 py-3">
<div className="flex items-center gap-3">
<TabsList className="grid h-9 flex-1 grid-cols-2 rounded-lg bg-muted/60 p-1">
<TabsTrigger value="audio" className="rounded-md text-sm">
<Mic className="h-4 w-4" />
Test Audio
</TabsTrigger>
<TabsTrigger value="text" className="rounded-md text-sm">
<MessageSquareText className="h-4 w-4" />
Test Chat
</TabsTrigger>
</TabsList>
{onClose ? (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Close tester panel"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
<TabsContent value="audio" className="min-h-0 flex-1 px-4 py-4">
<div className="flex h-full min-h-0 flex-col gap-3">
{!tokenReady ? (
<div className="space-y-4">
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-80 rounded-xl" />
</div>
) : !accessToken ? (
<DisabledNotice
reason={authUnavailableReason ?? "Authentication is required before browser tests can start."}
/>
) : voiceRunId ? (
<EmbeddedVoiceTester
workflowId={workflowId}
workflowRunId={voiceRunId}
initialContextVariables={initialContextVariables}
accessToken={accessToken}
onReset={() => setVoiceRunId(null)}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<>
{effectiveDisabledReason ? <DisabledNotice reason={effectiveDisabledReason} /> : null}
<EmptyState
icon={<Phone className="h-7 w-7" />}
title="Call this agent in the browser"
description="Test the agent over a voice call. Some telephony-only tools, like call transfer, are not yet supported here."
action={
<Button
ref={runTestButtonRef}
onClick={createVoiceRun}
disabled={creatingVoiceRun || testerBlocked}
>
{creatingVoiceRun ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting test...
</>
) : (
<>
<Phone className="h-4 w-4" />
Run Test
</>
)}
</Button>
}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="text" className="min-h-0 flex-1 px-4 py-3">
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<ChatModeToggle value={chatMode} onChange={setChatMode} />
{chatMode === "manual" && chatActive ? (
<Button
variant="ghost"
size="sm"
onClick={() => setChatSessionKey((value) => value + 1)}
disabled={testerBlocked}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5" />
Reset
</Button>
) : null}
</div>
{chatMode === "manual" ? (
<ManualTextChatPanel
key={chatSessionKey}
workflowId={workflowId}
ready={tokenReady && !!accessToken}
initialContextVariables={initialContextVariables}
disabled={testerBlocked}
disabledReason={effectiveDisabledReason}
onActiveChange={setChatActive}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<AiSimulatorPlaceholder disabledReason={effectiveDisabledReason} />
)}
</div>
</TabsContent>
</Tabs>
<OnboardingTooltip
targetRef={runTestButtonRef}
title="Try Your First Web Call"
message="Start a browser call here to hear the agent, inspect the transcript, and validate the workflow before you customize it further."
onDismiss={() => markTooltipSeen("web_call")}
showNext={false}
isVisible={showRunTestTooltip}
/>
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { DisabledNotice } from "./shared";
export function AiSimulatorPlaceholder({
disabledReason,
}: {
disabledReason: string | null;
}) {
const [simulatorPrompt, setSimulatorPrompt] = useState(
"Act like a skeptical prospect. Push on pricing, ask about integrations, and end the chat if the assistant becomes repetitive.",
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<p className="text-sm text-muted-foreground">
Drive multi-turn, agent-vs-agent tests with a persona prompt.
</p>
<Textarea
value={simulatorPrompt}
onChange={(event) => setSimulatorPrompt(event.target.value)}
placeholder="Describe the simulated user..."
className="min-h-32 resize-none text-sm leading-6"
/>
<Button size="sm" disabled className="self-start">
<Sparkles className="h-4 w-4" />
Coming soon
</Button>
</div>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
composerId: string;
draft: string;
ready: boolean;
editing: boolean;
sendingMessage: boolean;
inputDisabled: boolean;
onDraftChange: (value: string) => void;
onCancelEditing: () => void;
onSubmit: () => Promise<void> | void;
}
export function ChatComposer({
composerId,
draft,
ready,
editing,
sendingMessage,
inputDisabled,
onDraftChange,
onCancelEditing,
onSubmit,
}: ChatComposerProps) {
return (
<div className="pt-3">
{editing ? (
<div className="mb-2 flex items-center justify-between gap-2 rounded-lg border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<span>Edit the selected user message, then press Enter to rerun from that point.</span>
<button
type="button"
onClick={onCancelEditing}
className="inline-flex items-center gap-1 rounded text-foreground hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>
) : null}
<div className="relative">
<Textarea
id={composerId}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
placeholder={ready ? (editing ? "Edit and rerun this message..." : "Send a message...") : "Preparing chat..."}
rows={1}
className="min-h-11! resize-none pr-20 text-sm leading-6"
disabled={inputDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (sendingMessage) return;
void onSubmit();
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => void onSubmit()}
disabled={inputDisabled || sendingMessage || !draft.trim()}
className="absolute bottom-1.5 right-1.5 h-8 px-4"
>
{sendingMessage ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{editing ? "Rerunning" : "Sending"}
</>
) : (
editing ? "Rerun" : "Send"
)}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,162 @@
"use client";
import { Loader2, Phone, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { RealtimeFeedback } from "@/components/workflow/conversation";
import { ApiKeyErrorDialog, ConnectionStatus, WorkflowConfigErrorDialog } from "../../run/[runId]/components";
import { useWebSocketRTC } from "../../run/[runId]/hooks";
import type { WorkflowRuntimeNodeTransition } from "./types";
interface EmbeddedVoiceTesterProps {
workflowId: number;
workflowRunId: number;
initialContextVariables?: Record<string, string>;
accessToken: string;
onReset: () => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function EmbeddedVoiceTester({
workflowId,
workflowRunId,
initialContextVariables,
accessToken,
onReset,
onNodeTransition,
}: EmbeddedVoiceTesterProps) {
const router = useRouter();
const {
audioRef,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
feedbackMessages,
} = useWebSocketRTC({
workflowId,
workflowRunId,
accessToken,
initialContextVariables,
onNodeTransition,
});
const autoStartedRef = useRef(false);
useEffect(() => {
if (autoStartedRef.current) {
return;
}
autoStartedRef.current = true;
void start();
}, [start]);
const endButtonLabel = connectionActive
? "End Call"
: isCompleted
? "Start Another Test"
: connectionStatus === "failed"
? "Retry Call"
: "Starting Test...";
const handleFooterAction = async () => {
if (connectionActive) {
stop();
return;
}
if (isCompleted) {
onReset();
return;
}
if (connectionStatus === "failed") {
await start();
}
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border/70 bg-background">
<div className="min-h-0 flex-1 overflow-hidden bg-muted/15">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
<div className="border-t border-border/70 bg-background px-4 py-3">
<div className="flex flex-col gap-3">
<ConnectionStatus connectionStatus={connectionStatus} />
{permissionError ? (
<p className="text-center text-sm text-destructive">{permissionError}</p>
) : null}
<Button
onClick={handleFooterAction}
disabled={isStarting && connectionStatus !== "failed"}
variant={connectionActive ? "destructive" : "default"}
className="w-full"
>
{isStarting && connectionStatus !== "failed" ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Test...
</>
) : connectionActive ? (
<>
<Phone className="h-4 w-4" />
{endButtonLabel}
</>
) : connectionStatus === "failed" ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : isCompleted ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{endButtonLabel}
</>
)}
</Button>
</div>
</div>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={() => router.push("/api-keys")}
onNavigateToModelConfig={() => router.push("/model-configurations")}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={() => router.push(`/workflow/${workflowId}`)}
/>
</>
);
}

View file

@ -0,0 +1,145 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import type { ConversationItem } from "@/components/workflow/conversation";
import { ConversationTimeline } from "@/components/workflow/conversation";
import { ChatComposer } from "./ChatComposer";
import { DisabledNotice, ManualChatEmptyState, TypingIndicator } from "./shared";
import { TurnMessageActions } from "./TurnMessageActions";
import type { WorkflowRuntimeNodeTransition } from "./types";
import { useTextChatSession } from "./useTextChatSession";
interface ManualTextChatPanelProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function ManualTextChatPanel({
workflowId,
ready,
initialContextVariables,
disabled,
disabledReason,
onActiveChange,
onNodeTransition,
}: ManualTextChatPanelProps) {
const {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled,
conversationItems,
setDraft,
startSession,
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
} = useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
});
if (!started && !session) {
return (
<div className="flex h-full min-h-0 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<ManualChatEmptyState disabled={disabled} ready={ready} onStart={startSession} />
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{disabledReason ? (
<div className="pb-3">
<DisabledNotice reason={disabledReason} />
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
{creatingSession && !session ? (
<div className="space-y-3 py-1">
<Skeleton className="ml-auto h-9 w-2/3 rounded-2xl" />
<Skeleton className="h-12 w-3/4 rounded-2xl" />
</div>
) : turns.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">
{disabled
? (disabledReason ?? "Testing is paused.")
: "Send a message to start the conversation."}
</p>
</div>
) : (
<ConversationTimeline
items={conversationItems}
autoScroll={true}
scrollBehavior="smooth"
emptyState={{
title: "No conversation recorded",
subtitle: "Send a message to start the conversation.",
}}
pendingIndicator={sendingMessage ? <TypingIndicator /> : null}
className="py-1"
renderItemActions={(item: ConversationItem) => {
if (item.kind !== "message" || item.role !== "user" || !item.turnId) {
return null;
}
const turn = turns.find((candidate) => candidate.id === item.turnId);
if (!turn?.user_message) {
return null;
}
const rewindingThisTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "rewind";
const rerunningEditedTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "edit";
return (
<TurnMessageActions
disabled={disabled || sendingMessage}
editing={editingTurnId === turn.id}
rewinding={rewindingThisTurn}
rerunningEdit={rerunningEditedTurn}
onRewind={() => void rewindTurn(turn)}
onEdit={() => startEditingTurn(turn)}
/>
);
}}
/>
)}
</div>
<ChatComposer
composerId={composerId}
draft={draft}
ready={ready}
editing={!!editingTurn}
sendingMessage={sendingMessage}
inputDisabled={inputDisabled}
onDraftChange={setDraft}
onCancelEditing={cancelEditingTurn}
onSubmit={submitComposer}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { Loader2, Pencil, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface TurnMessageActionsProps {
disabled: boolean;
editing: boolean;
rewinding: boolean;
rerunningEdit: boolean;
onRewind: () => void;
onEdit: () => void;
}
export function TurnMessageActions({
disabled,
editing,
rewinding,
rerunningEdit,
onRewind,
onEdit,
}: TurnMessageActionsProps) {
return (
<>
<button
type="button"
onClick={onRewind}
disabled={disabled}
aria-label="Rerun this turn"
title="Rerun this turn"
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
>
{rewinding ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={onEdit}
disabled={disabled}
aria-label="Edit and rerun this turn"
title="Edit and rerun this turn"
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50",
editing && "bg-muted text-foreground",
)}
>
{rerunningEdit ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pencil className="h-3.5 w-3.5" />
)}
</button>
</>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { AlertCircle, MessageSquareText } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function DisabledNotice({ reason }: { reason: string }) {
return (
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">Testing is paused</p>
<p className="text-amber-800/90 dark:text-amber-300">{reason}</p>
</div>
</div>
</div>
);
}
export function EmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-1 flex-col justify-center rounded-xl border border-border/70 bg-background px-5 py-6 text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{icon}
</div>
<div className="mt-4 space-y-1.5">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
export function ChatModeToggle({
value,
onChange,
}: {
value: "manual" | "simulated";
onChange: (next: "manual" | "simulated") => void;
}) {
const options: Array<{ id: "manual" | "simulated"; label: string }> = [
{ id: "manual", label: "Manual" },
{ id: "simulated", label: "Simulated" },
];
return (
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
export function TypingIndicator() {
return (
<div className="flex justify-start">
<div className="rounded-2xl rounded-bl-md bg-muted px-3.5 py-3">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
</div>
</div>
);
}
export function ManualChatEmptyState({
disabled,
ready,
onStart,
}: {
disabled: boolean;
ready: boolean;
onStart: () => void;
}) {
return (
<EmptyState
icon={<MessageSquareText className="h-7 w-7" />}
title="Chat with this agent"
description="Test the agent over a text conversation. Send messages and see how it responds, with tool calls, transitions, and rewind support."
action={
<Button onClick={onStart} disabled={disabled || !ready}>
<MessageSquareText className="h-4 w-4" />
Start Test
</Button>
}
/>
);
}

View file

@ -0,0 +1,60 @@
import type { WorkflowRunTextSessionResponse } from "@/client/types.gen";
import type { ConversationNodeTransitionItem } from "@/components/workflow/conversation";
export interface TextChatMessage {
text: string;
created_at: string;
}
export interface TextChatTurn {
id: string;
status: string;
created_at: string;
user_message: TextChatMessage | null;
assistant_message: TextChatMessage | null;
events: Array<Record<string, unknown>>;
usage: Record<string, unknown>;
}
export interface TextChatSessionData {
version: number;
status: string;
cursor_turn_id: string | null;
turns: TextChatTurn[];
discarded_future: Array<Record<string, unknown>>;
simulator: {
enabled: boolean;
config: Record<string, unknown>;
};
}
export interface TextChatCheckpoint {
version: number;
anchor_turn_id: string | null;
current_node_id: string | null;
messages: Array<Record<string, unknown>>;
gathered_context: Record<string, unknown>;
tool_state: Record<string, unknown>;
}
export type TextChatSession = Omit<WorkflowRunTextSessionResponse, "session_data" | "checkpoint"> & {
session_data: TextChatSessionData;
checkpoint: TextChatCheckpoint;
};
export interface TurnActionState {
turnId: string;
type: "rewind" | "edit";
}
export type WorkflowRuntimeNodeTransition = ConversationNodeTransitionItem;
export const EMPTY_TEXT_CHAT_TURNS: TextChatTurn[] = [];
export function toTextChatSession(response: WorkflowRunTextSessionResponse): TextChatSession {
return {
...response,
session_data: response.session_data as unknown as TextChatSessionData,
checkpoint: response.checkpoint as unknown as TextChatCheckpoint,
};
}

View file

@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost,
createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost,
rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost,
} from "@/client/sdk.gen";
import { conversationItemsFromTextChatTurns } from "@/components/workflow/conversation/adapters/fromTextChatTurns";
import {
EMPTY_TEXT_CHAT_TURNS,
type TextChatSession,
type TextChatTurn,
toTextChatSession,
type TurnActionState,
type WorkflowRuntimeNodeTransition,
} from "./types";
import { extractSdkErrorMessage, getErrorMessage, getReplayCursorTurnId } from "./utils";
interface UseTextChatSessionProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
}: UseTextChatSessionProps) {
const [session, setSession] = useState<TextChatSession | null>(null);
const [started, setStarted] = useState(false);
const [draft, setDraft] = useState("");
const [creatingSession, setCreatingSession] = useState(false);
const [sendingMessage, setSendingMessage] = useState(false);
const [editingTurnId, setEditingTurnId] = useState<string | null>(null);
const [activeTurnAction, setActiveTurnAction] = useState<TurnActionState | null>(null);
const lastNotifiedNodeTransitionIdRef = useRef<string | null>(null);
const turns = session?.session_data.turns ?? EMPTY_TEXT_CHAT_TURNS;
const editingTurn = editingTurnId
? turns.find((turn) => turn.id === editingTurnId) ?? null
: null;
const composerId = `workflow-tester-compose-${workflowId}`;
const conversationItems = conversationItemsFromTextChatTurns(turns);
const createSession = useCallback(async () => {
if (disabled) return;
setCreatingSession(true);
try {
const response = await createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost({
path: { workflow_id: workflowId },
body: {
initial_context: initialContextVariables ?? {},
annotations: {
tester: {
source: "workflow_editor",
modality: "text",
ui_mode: "manual_text",
},
},
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create chat session"));
}
setSession(toTextChatSession(response.data));
setDraft("");
} catch (error) {
setSession(null);
setStarted(false);
toast.error(getErrorMessage(error));
} finally {
setCreatingSession(false);
}
}, [disabled, initialContextVariables, workflowId]);
useEffect(() => {
if (!started || creatingSession || session || !ready || disabled) {
return;
}
void createSession();
}, [createSession, creatingSession, disabled, ready, session, started]);
useEffect(() => {
onActiveChange?.(started);
}, [onActiveChange, started]);
useEffect(() => {
const latestNodeTransition = [...conversationItems]
.reverse()
.find(
(item): item is WorkflowRuntimeNodeTransition =>
item.kind === "node-transition" && !!item.nodeId,
);
if (!latestNodeTransition?.nodeId) {
return;
}
if (lastNotifiedNodeTransitionIdRef.current === latestNodeTransition.id) {
return;
}
lastNotifiedNodeTransitionIdRef.current = latestNodeTransition.id;
onNodeTransition?.(latestNodeTransition);
}, [conversationItems, onNodeTransition]);
useEffect(() => {
if (!editingTurnId) {
return;
}
if (!turns.some((turn) => turn.id === editingTurnId)) {
setEditingTurnId(null);
setDraft("");
}
}, [editingTurnId, turns]);
const submitMessage = useCallback(async (messageText: string, replayOptions?: TurnActionState) => {
const trimmedText = messageText.trim();
if (!session || !trimmedText || disabled) return;
setSendingMessage(true);
if (replayOptions) {
setActiveTurnAction(replayOptions);
}
try {
let activeSession = session;
if (replayOptions) {
const rewindResponse = await rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
cursor_turn_id: getReplayCursorTurnId(activeSession.session_data.turns, replayOptions.turnId),
expected_revision: activeSession.revision,
},
});
if (rewindResponse.error || !rewindResponse.data) {
throw new Error(extractSdkErrorMessage(rewindResponse.error, "Failed to rewind session"));
}
activeSession = toTextChatSession(rewindResponse.data);
setSession(activeSession);
}
const response = await appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
text: trimmedText,
expected_revision: activeSession.revision,
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to send message"));
}
setSession(toTextChatSession(response.data));
setDraft("");
setEditingTurnId(null);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setSendingMessage(false);
setActiveTurnAction(null);
}
}, [disabled, session, workflowId]);
const rewindTurn = useCallback(async (turn: TextChatTurn) => {
if (!turn.user_message) return;
await submitMessage(turn.user_message.text, { turnId: turn.id, type: "rewind" });
}, [submitMessage]);
const startEditingTurn = useCallback((turn: TextChatTurn) => {
if (!turn.user_message) return;
const nextText = turn.user_message.text;
setEditingTurnId(turn.id);
setDraft(nextText);
requestAnimationFrame(() => {
const textarea = document.getElementById(composerId) as HTMLTextAreaElement | null;
textarea?.focus();
textarea?.setSelectionRange(nextText.length, nextText.length);
});
}, [composerId]);
const cancelEditingTurn = useCallback(() => {
setEditingTurnId(null);
setDraft("");
}, []);
const submitComposer = useCallback(async () => {
if (editingTurnId) {
await submitMessage(draft, { turnId: editingTurnId, type: "edit" });
return;
}
await submitMessage(draft);
}, [draft, editingTurnId, submitMessage]);
return {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled: disabled || !session,
conversationItems,
setDraft,
startSession: () => setStarted(true),
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
};
}

View file

@ -0,0 +1,29 @@
export function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return "Something went wrong";
}
export function extractSdkErrorMessage(error: unknown, fallback: string) {
if (!error) return fallback;
if (typeof error === "string") return error;
if (typeof error === "object") {
const detail = (error as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (
detail &&
typeof detail === "object" &&
typeof (detail as { message?: unknown }).message === "string"
) {
return (detail as { message: string }).message;
}
}
return fallback;
}
export function getReplayCursorTurnId(turns: Array<{ id: string }>, turnId: string) {
const turnIndex = turns.findIndex((turn) => turn.id === turnId);
if (turnIndex < 0) {
throw new Error("Turn not found");
}
return turns[turnIndex - 1]?.id ?? null;
}

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

@ -1,6 +1,6 @@
'use client';
import { useParams } from 'next/navigation';
import { useParams, useSearchParams } from 'next/navigation';
import posthog from 'posthog-js';
import { useEffect, useMemo, useState } from 'react';
@ -18,6 +18,7 @@ import WorkflowLayout from '../WorkflowLayout';
export default function WorkflowDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -59,6 +60,7 @@ export default function WorkflowDetailPage() {
}, [params.workflowId, user]);
const stableUser = useMemo(() => user, [user]);
const openTesterOnLoad = searchParams.get('onboarding') === 'web_call';
if (loading) {
return (
@ -82,6 +84,8 @@ export default function WorkflowDetailPage() {
initialWorkflowName={workflow.name}
workflowId={workflow.id}
workflowUuid={workflow.workflow_uuid ?? undefined}
initialTotalRuns={workflow.total_runs ?? 0}
openTesterOnLoad={openTesterOnLoad}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],

View file

@ -1,184 +0,0 @@
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from "@/client/sdk.gen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
RealtimeFeedback,
WorkflowConfigErrorDialog
} from "./components";
import { useWebSocketRTC } from "./hooks";
const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const auth = useAuth();
const [accessToken, setAccessToken] = useState<string | null>(null);
const [checkingForRecording, setCheckingForRecording] = useState(false);
// Get access token for WebSocket connection (non-SDK usage)
useEffect(() => {
if (auth.isAuthenticated && !auth.loading) {
auth.getAccessToken().then(setAccessToken);
}
}, [auth]);
const {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
getAudioInputDevices,
feedbackMessages,
} = useWebSocketRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
// Poll for recording availability after call ends
useEffect(() => {
if (!isCompleted || !auth.isAuthenticated) return;
setCheckingForRecording(true);
const intervalId = setInterval(async () => {
try {
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
path: {
workflow_id: workflowId,
run_id: workflowRunId,
},
});
if (response.data?.transcript_url || response.data?.recording_url) {
setCheckingForRecording(false);
clearInterval(intervalId);
// Refresh the page to show the recording
window.location.reload();
}
} catch (error) {
console.error('Error checking for recording:', error);
}
}, 5000); // Check every 5 seconds
// Clean up after 2 minutes
const timeoutId = setTimeout(() => {
clearInterval(intervalId);
setCheckingForRecording(false);
}, 120000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [isCompleted, auth.isAuthenticated, workflowId, workflowRunId]);
const navigateToCredits = () => {
router.push('/api-keys');
};
const navigateToModelConfig = () => {
router.push('/model-configurations');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
return (
<>
<div className="flex h-screen w-full overflow-hidden">
{/* Main content - 2/3 width when panel visible, full width otherwise */}
<div className="w-2/3 h-full overflow-y-auto">
<div className="flex justify-center items-center h-full px-8">
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Call Voice Agent</CardTitle>
</CardHeader>
<CardContent>
{isCompleted && checkingForRecording ? (
<div className="flex flex-col items-center justify-center space-y-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center space-y-2">
<p className="text-foreground font-medium">Processing your call</p>
<p className="text-sm text-muted-foreground">Fetching transcript and recording...</p>
</div>
</div>
) : (
<>
<AudioControls
audioInputs={audioInputs}
selectedAudioInput={selectedAudioInput}
setSelectedAudioInput={setSelectedAudioInput}
isCompleted={isCompleted}
connectionActive={connectionActive}
permissionError={permissionError}
start={start}
stop={stop}
isStarting={isStarting}
getAudioInputDevices={getAudioInputDevices}
/>
<ConnectionStatus
connectionStatus={connectionStatus}
/>
</>
)}
</CardContent>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</Card>
</div>
</div>
{/* Show transcript panel */}
<div className="w-1/3 h-full shrink-0 overflow-hidden">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={navigateToCredits}
onNavigateToModelConfig={navigateToModelConfig}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={navigateToWorkflow}
/>
</>
);
};
export default BrowserCall;

View file

@ -1,174 +0,0 @@
'use client';
import { FeedbackMessage } from '../hooks/useWebSocketRTC';
import { processLiveMessages, processTranscriptEvents, TranscriptEvent } from '../utils/processTranscriptEvents';
import { UnifiedTranscript } from './UnifiedTranscript';
// Historical log event format from the backend
interface RealtimeFeedbackEvent {
type: string;
payload: {
text?: string;
final?: boolean;
user_id?: string;
timestamp?: string;
function_name?: string;
tool_call_id?: string;
result?: string;
node_name?: string;
previous_node?: string;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
timestamp: string;
turn: number;
}
export interface WorkflowRunLogs {
realtime_feedback_events?: RealtimeFeedbackEvent[];
}
// Props for live mode (WebSocket messages)
interface LiveModeProps {
mode: 'live';
messages: FeedbackMessage[];
isCallActive: boolean;
isCallCompleted: boolean;
}
// Props for historical mode (API logs)
interface HistoricalModeProps {
mode: 'historical';
logs: WorkflowRunLogs | null;
}
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
/**
* Convert backend log events to unified TranscriptEvent format
*/
function convertLogEventsToTranscriptEvents(events: RealtimeFeedbackEvent[]): TranscriptEvent[] {
return events.map(event => {
let type: TranscriptEvent['type'];
let status: TranscriptEvent['status'];
switch (event.type) {
case 'rtf-user-transcription':
type = 'user-transcription';
break;
case 'rtf-bot-text':
type = 'bot-text';
break;
case 'rtf-function-call-start':
type = 'function-call';
status = 'running';
break;
case 'rtf-function-call-end':
type = 'function-call';
status = 'completed';
break;
case 'rtf-node-transition':
type = 'node-transition';
break;
case 'rtf-ttfb-metric':
type = 'ttfb-metric';
break;
case 'rtf-pipeline-error':
type = 'pipeline-error';
break;
case 'rtf-interrupt-warning':
type = 'interrupt-warning';
break;
default:
type = 'bot-text';
}
return {
type,
text: event.payload.text || event.payload.error || event.payload.result || event.payload.function_name || event.payload.node_name || '',
final: event.payload.final,
timestamp: event.timestamp,
turn: event.turn,
functionName: event.payload.function_name,
status,
nodeName: event.payload.node_name,
previousNode: event.payload.previous_node,
allowInterrupt: event.payload.allow_interrupt,
ttfbSeconds: event.payload.ttfb_seconds,
processor: event.payload.processor,
model: event.payload.model,
fatal: event.payload.fatal,
};
});
}
/**
* Convert live WebSocket messages to unified TranscriptEvent format
*/
function convertLiveMessagesToTranscriptEvents(messages: FeedbackMessage[]): TranscriptEvent[] {
return messages.map(msg => ({
type: msg.type,
text: msg.text,
final: msg.final,
timestamp: msg.timestamp,
functionName: msg.functionName,
status: msg.status,
nodeName: msg.nodeName,
previousNode: msg.previousNode,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
processor: msg.processor,
model: msg.model,
fatal: msg.fatal,
}));
}
/**
* Single unified component that handles both live WebSocket messages
* and historical logs from the API.
*/
export const RealtimeFeedback = (props: RealtimeFeedbackProps) => {
if (props.mode === 'historical') {
// Historical mode - process logs from API
const rawEvents = props.logs?.realtime_feedback_events;
const messages = rawEvents
? processTranscriptEvents(convertLogEventsToTranscriptEvents(rawEvents))
: [];
return (
<UnifiedTranscript
messages={messages}
status="ended"
title="Call Transcript"
emptyState={{
title: "No conversation recorded",
subtitle: "Real-time feedback events were not captured for this call"
}}
/>
);
}
// Live mode - process WebSocket messages (optimized - messages already accumulated)
const { messages, isCallActive, isCallCompleted } = props;
const status = isCallActive ? 'live' : isCallCompleted ? 'ended' : 'ready';
const processedMessages = processLiveMessages(convertLiveMessagesToTranscriptEvents(messages));
return (
<UnifiedTranscript
messages={processedMessages}
status={status}
title="Live Transcript"
autoScroll={true}
emptyState={{
title: "No messages yet",
subtitle: isCallActive
? "Start speaking to see the transcript"
: "Start the call to begin the conversation"
}}
/>
);
};

View file

@ -1,98 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { ProcessedMessage } from "../utils/processTranscriptEvents";
import { TranscriptContainer } from "./shared/TranscriptContainer";
import { TranscriptEmptyState } from "./shared/TranscriptEmptyState";
import { TranscriptMessage, TranscriptMessageData } from "./shared/TranscriptMessage";
interface UnifiedTranscriptProps {
messages: ProcessedMessage[];
status: 'ready' | 'live' | 'ended';
title?: string;
autoScroll?: boolean;
emptyState?: {
title: string;
subtitle: string;
};
}
export const UnifiedTranscript = ({
messages,
status,
title,
autoScroll = false,
emptyState
}: UnifiedTranscriptProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive (for live mode)
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, autoScroll]);
// Calculate message count (exclude system messages like function calls, node transitions, TTFB)
const messageCount = messages.filter(
m => m.type === 'user-transcription' || m.type === 'bot-text'
).length;
// Convert ProcessedMessage to TranscriptMessageData
const transcriptMessages: TranscriptMessageData[] = messages.map(msg => ({
id: msg.id,
type: msg.type,
text: msg.text,
final: msg.final,
functionName: msg.functionName,
status: msg.status,
nodeName: msg.nodeName,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
fatal: msg.fatal,
}));
// Default empty state
const defaultEmptyState = {
title: status === 'live' ? "No messages yet" : "No conversation recorded",
subtitle: status === 'live'
? "Start speaking to see the transcript"
: "Real-time feedback events were not captured"
};
const emptyStateToShow = emptyState || defaultEmptyState;
return (
<TranscriptContainer
title={title || (status === 'live' ? 'Live Transcript' : 'Call Transcript')}
status={status}
messageCount={messageCount > 0 ? messageCount : undefined}
>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{messages.length === 0 ? (
<TranscriptEmptyState
title={emptyStateToShow.title}
subtitle={emptyStateToShow.subtitle}
/>
) : (
<div className="space-y-3 p-4">
{transcriptMessages.map((msg, index) => {
// Skip standalone TTFB metrics (they're rendered inline with bot text)
if (msg.type === 'ttfb-metric') {
return null;
}
return (
<TranscriptMessage
key={`${msg.id}-${index}`}
message={msg}
nextMessage={transcriptMessages[index + 1]}
/>
);
})}
</div>
)}
</div>
</TranscriptContainer>
);
};

View file

@ -2,5 +2,4 @@ export * from './ApiKeyErrorDialog';
export * from './AudioControls';
export * from './ConnectionStatus';
export * from './ContextDisplay';
export * from './RealtimeFeedback';
export * from './WorkflowConfigErrorDialog';

View file

@ -1,72 +0,0 @@
'use client';
import { MessageSquare, Mic, MicOff } from 'lucide-react';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
type CallStatus = 'ready' | 'live' | 'ended';
interface TranscriptContainerProps {
title: string;
status: CallStatus;
children: ReactNode;
messageCount?: number;
}
const STATUS_CONFIG = {
ready: {
icon: MicOff,
label: 'Ready',
className: 'bg-muted text-muted-foreground',
},
live: {
icon: Mic,
label: 'Live',
className: 'bg-green-500/10 text-green-600 dark:text-green-400',
},
ended: {
icon: MicOff,
label: 'Ended',
className: 'bg-muted text-muted-foreground',
},
};
export function TranscriptContainer({
title,
status,
children,
messageCount
}: TranscriptContainerProps) {
const statusConfig = STATUS_CONFIG[status];
const StatusIcon = statusConfig.icon;
return (
<div className="w-full h-full flex flex-col bg-background border-l border-border">
{/* Header */}
<div className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm whitespace-nowrap">{title}</span>
<div className={cn(
"flex items-center gap-1 text-xs px-2 py-0.5 rounded-full shrink-0",
statusConfig.className
)}>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</div>
</div>
{/* Content */}
{children}
{/* Footer with message count */}
{messageCount !== undefined && messageCount > 0 && (
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground shrink-0">
{messageCount} messages
</div>
)}
</div>
);
}

View file

@ -1,20 +0,0 @@
'use client';
import { MessageSquare } from 'lucide-react';
interface TranscriptEmptyStateProps {
title: string;
subtitle: string;
}
export function TranscriptEmptyState({ title, subtitle }: TranscriptEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
<MessageSquare className="h-10 w-10 mb-4 opacity-30" />
<p className="font-medium">{title}</p>
<p className="text-xs mt-1 text-center px-4">
{subtitle}
</p>
</div>
);
}

View file

@ -1,154 +0,0 @@
'use client';
import { AlertTriangle, Brain, ExternalLink, GitBranch, MicOff, Wrench } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface TranscriptMessageData {
id: string;
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
functionName?: string;
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
interface TranscriptMessageProps {
message: TranscriptMessageData;
nextMessage?: TranscriptMessageData;
}
export function TranscriptMessage({ message, nextMessage }: TranscriptMessageProps) {
// Node transition - show as section divider
if (message.type === 'node-transition') {
return (
<div className="flex items-center gap-2 py-2">
<div className="flex-1 h-px bg-border"></div>
<div className="px-2 py-1 rounded-md text-xs bg-blue-500/10 border border-blue-500/20 inline-flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-blue-500" />
<span className="font-medium text-blue-700 dark:text-blue-400">
{message.nodeName}
</span>
</div>
<div className="flex-1 h-px bg-border"></div>
</div>
);
}
// Interrupt warning - show as an amber alert (one-time)
if (message.type === 'interrupt-warning') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<MicOff className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
Interruption Disabled
</div>
<div className="text-sm text-amber-600 dark:text-amber-300 mt-0.5">
{message.text}
</div>
<a
href="https://docs.dograh.com/configurations/interruption"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:underline mt-1"
>
Learn more <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
);
}
// Pipeline error - show as a red alert
if (message.type === 'pipeline-error') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-red-700 dark:text-red-400">
{message.fatal ? 'Fatal Pipeline Error' : 'Pipeline Error'}
</div>
<div className="text-sm text-red-600 dark:text-red-300 mt-0.5 break-words">
{message.text}
</div>
</div>
</div>
);
}
// TTFB metric - don't render standalone, it'll be shown with bot messages and function calls
if (message.type === 'ttfb-metric') {
return null;
}
// Function call message - centered with TTFB if present
if (message.type === 'function-call') {
const ttfbMetric = nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
return (
<div className="flex flex-col items-center gap-1">
{/* Show TTFB metric above function call */}
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
</div>
)}
<div className="px-3 py-1.5 rounded-full text-xs bg-amber-500/10 border border-amber-500/20 inline-flex items-center gap-2">
<Wrench className="h-3 w-3 text-amber-500" />
<span className="font-mono text-amber-700 dark:text-amber-400">
{message.functionName}()
</span>
</div>
</div>
);
}
const isUser = message.type === 'user-transcription';
const isBot = message.type === 'bot-text';
// Check if next message is a TTFB metric (for bot messages)
const ttfbMetric = isBot && nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
// User messages on right, bot messages on left
return (
<div className={cn(
"flex",
isUser ? "justify-end" : "justify-start"
)}>
<div className="flex flex-col gap-1 max-w-[85%]">
{/* Show TTFB metric above bot messages */}
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-1">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
</div>
)}
<div
className={cn(
"px-3 py-2 rounded-2xl text-sm",
isUser
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted rounded-bl-md",
!message.final && "opacity-70"
)}
>
<div className="whitespace-pre-wrap leading-relaxed">{message.text}</div>
{!message.final && (
<div className={cn(
"text-[10px] mt-1 italic",
isUser ? "text-primary-foreground/70" : "text-muted-foreground"
)}>
speaking...
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { client } from "@/client/client.gen";
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
import logger from '@/lib/logger';
@ -15,29 +16,10 @@ interface UseWebSocketRTCProps {
workflowRunId: number;
accessToken: string | null;
initialContextVariables?: Record<string, string> | null;
onNodeTransition?: (transition: ConversationNodeTransitionItem) => void;
}
export interface FeedbackMessage {
id: string;
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
// Node transition fields
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
// TTFB metric fields
ttfbSeconds?: number;
processor?: string;
model?: string;
// Pipeline error fields
fatal?: boolean;
}
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebSocketRTCProps) => {
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
const [connectionActive, setConnectionActive] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
@ -72,6 +54,11 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const timeStartRef = useRef<number | null>(null);
const onNodeTransitionRef = useRef(onNodeTransition);
useEffect(() => {
onNodeTransitionRef.current = onNodeTransition;
}, [onNodeTransition]);
// Generate a cryptographically secure unique ID
const generateSecureId = () => {
@ -379,18 +366,22 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
}
case 'rtf-function-call-start': {
const { function_name, tool_call_id } = message.payload;
const { function_name, tool_call_id, arguments: toolArguments } = message.payload;
setFeedbackMessages(prev => {
// Check if we already have this function call
const existingId = `func-${tool_call_id}`;
const existingId = tool_call_id
? `func-${tool_call_id}`
: `func-${Date.now()}`;
if (prev.some(msg => msg.id === existingId)) {
return prev;
}
return [...prev, {
id: existingId,
type: 'function-call',
text: function_name,
functionName: function_name,
text: function_name ?? 'tool',
functionName: function_name ?? 'tool',
toolCallId: tool_call_id,
arguments: toolArguments,
status: 'running',
timestamp: new Date().toISOString(),
}];
@ -402,24 +393,44 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const { tool_call_id, result } = message.payload;
setFeedbackMessages(prev => prev.map(msg =>
msg.id === `func-${tool_call_id}`
? { ...msg, status: 'completed' as const, text: result || msg.text }
? { ...msg, status: 'completed' as const, text: result || msg.text, result }
: msg
));
break;
}
case 'rtf-node-transition': {
const { node_name, previous_node_name, allow_interrupt } = message.payload;
const {
node_id,
node_name,
previous_node_id,
previous_node_name,
allow_interrupt,
} = message.payload;
currentAllowInterruptRef.current = allow_interrupt;
setFeedbackMessages(prev => [...prev, {
const transitionTimestamp = new Date().toISOString();
const transition: ConversationNodeTransitionItem = {
kind: 'node-transition',
id: `node-${Date.now()}`,
timestamp: transitionTimestamp,
nodeId: node_id,
nodeName: node_name ?? 'Node',
previousNodeId: previous_node_id,
previousNodeName: previous_node_name,
allowInterrupt: allow_interrupt,
};
setFeedbackMessages(prev => [...prev, {
id: transition.id,
type: 'node-transition',
text: node_name,
nodeName: node_name,
text: transition.nodeName,
nodeId: transition.nodeId,
nodeName: transition.nodeName,
previousNodeId: transition.previousNodeId,
previousNode: previous_node_name,
allowInterrupt: allow_interrupt,
timestamp: new Date().toISOString(),
timestamp: transitionTimestamp,
}]);
onNodeTransitionRef.current?.(transition);
break;
}

View file

@ -1,40 +1,107 @@
'use client';
import { Check, Copy, ExternalLink, FileText, LoaderCircle, Phone, Video } from 'lucide-react';
import { Check, Copy, ExternalLink, FileText, Video } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import posthog from 'posthog-js';
import { useEffect, useRef, useState } from 'react';
import BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
import { RealtimeFeedback, WorkflowRunLogs } from '@/app/workflow/[workflowId]/run/[runId]/components/RealtimeFeedback';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet,
} from '@/client/sdk.gen';
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ConversationRailFrame, RealtimeFeedback, WorkflowRunLogs } from '@/components/workflow/conversation';
import { PostHogEvent } from '@/constants/posthog-events';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useOnboarding } from '@/context/OnboardingContext';
import { useAuth } from '@/lib/auth';
import { downloadFile } from '@/lib/files';
import { getRandomId } from '@/lib/utils';
interface WorkflowRunResponse {
mode: string;
is_completed: boolean;
transcript_url: string | null;
recording_url: string | null;
cost_info: {
dograh_token_usage?: number | null;
call_duration_seconds?: number | null;
} | null;
initial_context: Record<string, string | number | boolean | object> | null;
gathered_context: Record<string, string | number | boolean | object> | null;
logs: WorkflowRunLogs | null;
annotations: Record<string, unknown> | null;
}
const RUN_SHELL_HEIGHT_CLASS = "h-[calc(100svh-49px)] min-h-[calc(100svh-49px)] max-h-[calc(100svh-49px)]";
function formatDuration(seconds?: number | null) {
if (seconds == null || Number.isNaN(seconds)) return 'N/A';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins === 0) return `${secs}s`;
return `${mins}m ${secs}s`;
}
function getTranscriptMetrics(logs: WorkflowRunLogs | null, gatheredContext: Record<string, string | number | boolean | object> | null) {
const events = logs?.realtime_feedback_events ?? [];
const userTurns = events.filter((event) => event.type === 'rtf-user-transcription' && event.payload.final).length;
const botTurns = events.filter((event) => event.type === 'rtf-bot-text').length;
const toolCalls = events.filter((event) => event.type === 'rtf-function-call-end').length;
const nodeNames = new Set(
events
.map((event) => event.payload.node_name)
.filter((nodeName): nodeName is string => Boolean(nodeName))
);
const visitedNodes = Array.isArray(gatheredContext?.nodes_visited)
? gatheredContext.nodes_visited.length
: nodeNames.size;
return { userTurns, botTurns, toolCalls, visitedNodes };
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border bg-muted/40 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{label}</p>
<p className="mt-2 text-lg font-semibold text-foreground">{value}</p>
</div>
);
}
function RunMetricsSection({
costInfo,
logs,
gatheredContext,
}: {
costInfo: WorkflowRunResponse['cost_info'];
logs: WorkflowRunLogs | null;
gatheredContext: Record<string, string | number | boolean | object> | null;
}) {
const metrics = getTranscriptMetrics(logs, gatheredContext);
return (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Run Metrics</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard label="Duration" value={formatDuration(costInfo?.call_duration_seconds)} />
<MetricCard
label="Token Usage"
value={costInfo?.dograh_token_usage != null ? costInfo.dograh_token_usage.toLocaleString() : 'N/A'}
/>
<MetricCard label="User Turns" value={String(metrics.userTurns)} />
<MetricCard label="Bot Turns" value={String(metrics.botTurns)} />
<MetricCard label="Tool Calls" value={String(metrics.toolCalls)} />
<MetricCard label="Nodes Visited" value={String(metrics.visitedNodes)} />
</CardContent>
</Card>
);
}
function ContextDisplay({ title, context }: { title: string; context: Record<string, string | number | boolean | object> | null }) {
const [copied, setCopied] = useState(false);
@ -79,9 +146,7 @@ function ContextDisplay({ title, context }: { title: string; context: Record<str
export default function WorkflowRunPage() {
const params = useParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [startingCall, setStartingCall] = useState(false);
const auth = useAuth();
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
@ -94,12 +159,6 @@ export default function WorkflowRunPage() {
}
}, [auth]);
// Shrink and reposition Chatwoot bubble on this page
useEffect(() => {
document.body.classList.add('chatwoot-compact');
return () => document.body.classList.remove('chatwoot-compact');
}, []);
const { openPreview, dialog } = MediaPreviewDialog();
useEffect(() => {
@ -117,9 +176,11 @@ export default function WorkflowRunPage() {
});
setIsLoading(false);
const runData = {
mode: response.data?.mode ?? '',
is_completed: response.data?.is_completed ?? false,
transcript_url: response.data?.transcript_url ?? null,
recording_url: response.data?.recording_url ?? null,
cost_info: response.data?.cost_info ?? null,
initial_context: response.data?.initial_context as Record<string, string> | null ?? null,
gathered_context: response.data?.gathered_context as Record<string, string> | null ?? null,
logs: response.data?.logs as WorkflowRunLogs | null ?? null,
@ -137,25 +198,9 @@ export default function WorkflowRunPage() {
fetchWorkflowRun();
}, [params.workflowId, params.runId, auth]);
const handleTestAgain = async () => {
if (startingCall) return;
setStartingCall(true);
try {
const workflowId = Number(params.workflowId);
const workflowRunName = `WR-${getRandomId()}`;
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: { workflow_id: workflowId },
body: { mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, name: workflowRunName },
});
if (response.data?.id) {
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
}
} finally {
setStartingCall(false);
}
};
let returnValue = null;
const isTextChatRun = workflowRun?.mode === WORKFLOW_RUN_MODES.TEXTCHAT;
const showRunDetailsView = Boolean(workflowRun?.is_completed || isTextChatRun);
if (isLoading) {
returnValue = (
@ -179,36 +224,28 @@ export default function WorkflowRunPage() {
</div>
);
}
else if (workflowRun?.is_completed) {
else if (showRunDetailsView) {
returnValue = (
<div className="flex h-screen w-full overflow-hidden">
{/* Main content - 2/3 width */}
<div className="w-2/3 h-full overflow-y-auto">
<div className="w-full max-w-4xl space-y-6 p-6">
<div className={`flex ${RUN_SHELL_HEIGHT_CLASS} min-h-0 w-full overflow-hidden bg-background`}>
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl space-y-6 p-6">
<Card className="border-border">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-4">
<CardTitle className="text-2xl">Agent Run Completed</CardTitle>
<div className="h-8 w-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
<CardTitle className="text-2xl">
{isTextChatRun ? 'Text Chat Session' : 'Agent Run Completed'}
</CardTitle>
<div className={`h-8 w-8 rounded-full flex items-center justify-center ${isTextChatRun ? 'bg-sky-500/15' : 'bg-emerald-500/20'}`}>
{isTextChatRun ? (
<FileText className="h-5 w-5 text-sky-500" />
) : (
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTestAgain}
disabled={startingCall}
variant="outline"
className="gap-2"
>
{startingCall ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Phone className="h-4 w-4" />
)}
{startingCall ? 'Starting...' : 'Test Again'}
</Button>
<Link href={`/workflow/${params.workflowId}`}>
<Button
ref={customizeButtonRef}
@ -228,41 +265,49 @@ export default function WorkflowRunPage() {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
<p className="text-muted-foreground mb-8">
{isTextChatRun
? 'Review the conversation history, metrics, and context captured for this text session.'
: 'Your voice agent run has been completed successfully. You can preview or download the transcript and recording.'}
</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
{!isTextChatRun && (
<>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url ?? null)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url ?? null)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
</>
)}
{workflowRun?.gathered_context?.trace_url && (
<div className="flex items-center gap-2 border-l border-border pl-4">
<div className={`flex items-center gap-2 ${isTextChatRun ? '' : 'border-l border-border pl-4'}`}>
<span className="text-sm text-muted-foreground">Trace:</span>
<Button
asChild
@ -285,14 +330,20 @@ export default function WorkflowRunPage() {
</CardContent>
</Card>
<RunMetricsSection
costInfo={workflowRun?.cost_info ?? null}
logs={workflowRun?.logs ?? null}
gatheredContext={workflowRun?.gathered_context ?? null}
/>
<div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
context={workflowRun?.initial_context}
context={workflowRun?.initial_context ?? null}
/>
<ContextDisplay
title="Gathered Context"
context={workflowRun?.gathered_context}
context={workflowRun?.gathered_context ?? null}
/>
</div>
@ -305,33 +356,34 @@ export default function WorkflowRunPage() {
</div>
</div>
{/* Transcript panel - 1/3 width */}
<div className="w-1/3 h-full shrink-0 overflow-hidden">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs} />
<div className="h-full min-h-0 w-[420px] shrink-0 border-l border-border bg-background p-5">
<ConversationRailFrame className="h-full">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs ?? null} />
</ConversationRailFrame>
</div>
</div>
);
}
else {
returnValue =
<div className="h-full flex items-center justify-center">
<BrowserCall
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}
initialContextVariables={
workflowRun?.initial_context
? Object.fromEntries(
Object.entries(workflowRun.initial_context).map(([key, value]) => [
key,
typeof value === 'object' && value !== null
? JSON.stringify(value)
: String(value)
])
)
: null
}
/>
returnValue = (
<div className="flex h-full items-center justify-center p-6">
<Card className="w-full max-w-xl border-border">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl">Run Details Unavailable</CardTitle>
<p className="text-sm text-muted-foreground">
This run does not have a details view yet. Go back to the workflow to continue testing or make changes.
</p>
</CardHeader>
<CardFooter>
<Button asChild className="gap-2">
<Link href={`/workflow/${params.workflowId}`}>
Customize Agent
</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
@ -340,7 +392,7 @@ export default function WorkflowRunPage() {
{dialog}
{/* Onboarding Tooltip for Customize Workflow */}
{workflowRun?.is_completed && (
{showRunDetailsView && (
<OnboardingTooltip
title='Customize Your Workflow'
targetRef={customizeButtonRef}

View file

@ -1,153 +0,0 @@
/**
* Utility to process realtime feedback events into a unified transcript format.
* Used by both live WebSocket messages and post-call logs.
*/
export interface TranscriptEvent {
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
turn?: number;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface ProcessedMessage {
id: string;
type: TranscriptEvent['type'];
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
/**
* Process transcript events (both live and historical).
* Combines consecutive bot-text by turn and associates TTFB metrics.
*/
export function processTranscriptEvents(events: TranscriptEvent[]): ProcessedMessage[] {
// Filter out interim transcriptions and function-call-start events
const filteredEvents = events.filter(event => {
if (event.type === 'user-transcription' && !event.final) return false;
if (event.type === 'function-call' && event.status === 'running') return false;
return true;
});
const processed: ProcessedMessage[] = [];
let currentBotText: { event: TranscriptEvent; text: string } | null = null;
let pendingTtfb: TranscriptEvent | null = null;
const flushBotText = () => {
if (!currentBotText) return;
processed.push(convertToProcessedMessage(currentBotText.event, currentBotText.text));
// Add the pending TTFB metric if it exists
if (pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
currentBotText = null;
};
for (const event of filteredEvents) {
if (event.type === 'ttfb-metric') {
// Store TTFB to associate with the next bot-text or function-call
pendingTtfb = event;
} else if (event.type === 'bot-text') {
// Combine consecutive bot-text from the same turn
if (currentBotText && currentBotText.event.turn === event.turn) {
currentBotText.text = currentBotText.text + ' ' + event.text;
} else {
flushBotText();
currentBotText = { event, text: event.text };
}
} else {
// Handle other events (user-transcription, function-call, node-transition)
flushBotText();
processed.push(convertToProcessedMessage(event));
// Add pending TTFB after function calls
if (event.type === 'function-call' && pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
}
}
// Flush any remaining bot text
flushBotText();
return processed;
}
/**
* Process live messages - optimized version.
*
* Optimizations rely on useWebSocketRTC.tsx already handling:
* - Bot text accumulation (consecutive chunks combined with spaces)
* - Interim transcription filtering (only final transcriptions kept)
* - Function call status (start events filtered, only completed kept)
*
* This function only needs to:
* - Associate TTFB metrics with the preceding bot-text or function-call
* - Convert to ProcessedMessage format
*/
export function processLiveMessages(messages: TranscriptEvent[]): ProcessedMessage[] {
const processed: ProcessedMessage[] = [];
let pendingTtfb: TranscriptEvent | null = null;
for (const msg of messages) {
if (msg.type === 'ttfb-metric') {
// Store TTFB to associate with next message
pendingTtfb = msg;
} else {
// Add the message
processed.push(convertToProcessedMessage(msg));
// Add pending TTFB after final bot-text or completed function calls
if ((msg.type === 'bot-text' && msg.final) ||
(msg.type === 'function-call' && msg.status === 'completed')) {
if (pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
}
}
}
return processed;
}
// Alias for backward compatibility
export const processHistoricalEvents = processTranscriptEvents;
function convertToProcessedMessage(event: TranscriptEvent, overrideText?: string): ProcessedMessage {
return {
id: `${event.type}-${event.timestamp}`,
type: event.type,
text: overrideText ?? event.text,
final: event.final ?? true,
timestamp: event.timestamp,
functionName: event.functionName,
status: event.status,
nodeName: event.nodeName,
allowInterrupt: event.allowInterrupt,
ttfbSeconds: event.ttfbSeconds,
fatal: event.fatal,
};
}

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

View file

@ -3,7 +3,7 @@
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -18,10 +18,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { getRandomId } from '@/lib/utils';
export default function CreateWorkflowPage() {
const router = useRouter();
@ -76,36 +74,9 @@ export default function CreateWorkflowPage() {
}
};
const handleModalContinue = async () => {
if (!workflowId || !user) return;
try {
const accessToken = await getAccessToken();
const workflowRunName = `WR-${getRandomId()}`;
// Create a workflow run
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
workflow_id: Number(workflowId),
},
body: {
mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, // Same mode as "Web Call" button
name: workflowRunName
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Navigate to the workflow run page
if (response.data?.id) {
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
}
} catch (err) {
logger.error(`Error creating workflow run: ${err}`);
// Fallback to workflow page if run creation fails
router.push(`/workflow/${workflowId}`);
}
const handleModalContinue = () => {
if (!workflowId) return;
router.push(`/workflow/${workflowId}?onboarding=web_call`);
};
return (
@ -233,7 +204,7 @@ export default function CreateWorkflowPage() {
The voice bot is pre-set to communicate in English with an American accent.
</p>
<p>
Next steps would be to test the voice bot using web call, and then modify it to suit your use case.
Next steps would be to test the voice bot in the editor, and then modify it to suit your use case.
</p>
</div>
</DialogDescription>
@ -243,7 +214,7 @@ export default function CreateWorkflowPage() {
onClick={handleModalContinue}
className="w-full"
>
Start Web Call
Open and Test Agent
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1,10 +1,12 @@
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
import type { WorkflowListResponse } from '@/client/types.gen';
import { getWorkflowsApiV1WorkflowFetchGet, listFoldersApiV1FolderGet } from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { AgentFolderView } from '@/components/workflow/folders/AgentFolderView';
import { CreateFolderButton } from '@/components/workflow/folders/CreateFolderButton';
import { FolderSection } from '@/components/workflow/folders/FolderSection';
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
import logger from '@/lib/logger';
@ -54,13 +56,27 @@ async function WorkflowList() {
.filter((w: WorkflowListResponse) => w.status === 'archived')
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
// Fetch folders for grouping active agents. A failure here shouldn't
// break the page — fall back to an empty list (flat, ungrouped view).
let folders: FolderResponse[] = [];
try {
const foldersResponse = await listFoldersApiV1FolderGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
folders = foldersResponse.data ?? [];
} catch (folderErr) {
logger.error(`Error fetching folders: ${folderErr}`);
}
return (
<>
{/* Active Workflows Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Active Agents</h2>
{activeWorkflows.length > 0 ? (
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
{activeWorkflows.length > 0 || folders.length > 0 ? (
<AgentFolderView workflows={activeWorkflows} folders={folders} />
) : (
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
@ -68,11 +84,10 @@ async function WorkflowList() {
)}
</div>
{/* Archived Workflows Section */}
{/* Archived Section — collapsible, same design as the folder/Uncategorized sections */}
{archivedWorkflows.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 text-muted-foreground">Archived Workflows</h2>
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
<FolderSection kind="archived" workflows={archivedWorkflows} />
</div>
)}
</>
@ -99,6 +114,7 @@ async function PageContent() {
<h1 className="text-2xl font-bold">Your Agents</h1>
<div className="flex gap-2">
<UploadWorkflowButton />
<CreateFolderButton />
<CreateWorkflowButton />
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
"use client";
import { ArrowDownLeft, ArrowUpRight, Globe, MessageSquare, Phone } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const WEB_CALL_MODES = new Set(["webrtc", "smallwebrtc"]);
const TEXT_CHAT_MODES = new Set(["textchat"]);
const getCallChannel = (mode?: string | null): "phone" | "web" | "chat" => {
if (mode && TEXT_CHAT_MODES.has(mode)) return "chat";
if (mode && WEB_CALL_MODES.has(mode)) return "web";
return "phone";
};
export function CallTypeCell({
mode,
callType,
}: {
mode?: string | null;
callType?: string | null;
}) {
if (!mode && !callType) {
return <span className="text-sm text-muted-foreground">-</span>;
}
const channel = getCallChannel(mode);
const ChannelIcon = channel === "chat" ? MessageSquare : channel === "web" ? Globe : Phone;
const channelLabel = channel === "chat" ? "Text chat" : channel === "web" ? "Web call" : "Phone call";
const isInbound = callType === "inbound";
const DirectionIcon = isInbound ? ArrowDownLeft : ArrowUpRight;
const directionLabel = isInbound ? "Inbound" : "Outbound";
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1">
<ChannelIcon className="h-4 w-4 text-muted-foreground" />
<DirectionIcon
className={`h-3.5 w-3.5 ${isInbound ? "text-emerald-600" : "text-blue-600"}`}
/>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={4}>
{directionLabel} · {channelLabel}
</TooltipContent>
</Tooltip>
);
}

View file

@ -1,5 +1,6 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
declare global {
@ -22,7 +23,22 @@ const CHATWOOT_BASE_URL = process.env.NEXT_PUBLIC_CHATWOOT_URL;
const CHATWOOT_WEBSITE_TOKEN = process.env.NEXT_PUBLIC_CHATWOOT_TOKEN;
export default function ChatwootWidget() {
const pathname = usePathname();
useEffect(() => {
const isWorkflowPage = /^\/workflow\/[^/]+(?:\/.*)?$/.test(pathname);
if (isWorkflowPage) {
document.getElementById("cw-widget-holder")?.remove();
document.getElementById("cw-bubble-holder")?.remove();
document.getElementById("cw-widget-styles")?.remove();
document
.querySelector(`script[src="${CHATWOOT_BASE_URL}/packs/js/sdk.js"]`)
?.remove();
delete window.chatwootSettings;
return;
}
// Don't initialize if environment variables are not set
if (!CHATWOOT_BASE_URL || !CHATWOOT_WEBSITE_TOKEN) {
console.warn("Chatwoot not configured: Missing NEXT_PUBLIC_CHATWOOT_URL or NEXT_PUBLIC_CHATWOOT_TOKEN");
@ -72,7 +88,7 @@ export default function ChatwootWidget() {
};
document.body.appendChild(script);
}, []);
}, [pathname]);
return null;
}

View file

@ -1,6 +1,6 @@
"use client";
import { Plus, X } from "lucide-react";
import { ExternalLink, Plus, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@ -13,6 +13,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { VoiceSelector } from "@/components/VoiceSelector";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { useUserConfig } from "@/context/UserConfigContext";
@ -30,9 +31,14 @@ interface SchemaProperty {
$ref?: string;
description?: string;
format?: string;
multiline?: boolean;
docs_url?: string;
}
interface ProviderSchema {
title?: string;
description?: string;
provider_docs_url?: string;
properties: Record<string, SchemaProperty>;
required?: string[];
$defs?: Record<string, SchemaProperty>;
@ -86,12 +92,24 @@ export interface ServiceConfigurationFormProps {
submitLabel?: string;
}
function getGlobalSummary(config: Record<string, unknown> | null | undefined): string {
function getProviderDisplayName(
provider: string | undefined,
providerSchema: ProviderSchema | undefined,
): string | undefined {
if (!provider) return provider;
return providerSchema?.title || provider;
}
function getGlobalSummary(
config: Record<string, unknown> | null | undefined,
providerSchema: ProviderSchema | undefined,
): string {
if (!config) return "Not configured";
const provider = config.provider as string | undefined;
const model = config.model as string | undefined;
if (!provider) return "Not configured";
return model ? `${provider} / ${model}` : provider;
const providerLabel = getProviderDisplayName(provider, providerSchema);
return model ? `${providerLabel} / ${model}` : providerLabel || provider;
}
export function ServiceConfigurationForm({
@ -484,11 +502,26 @@ export function ServiceConfigurationForm({
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
{getProviderDisplayName(provider, schemas?.[service]?.[provider])}
</SelectItem>
))}
</SelectContent>
</Select>
{(providerSchema?.description || providerSchema?.provider_docs_url) && (
<p className="text-xs text-muted-foreground">
{providerSchema?.description}{" "}
{providerSchema?.provider_docs_url && (
<a
href={providerSchema.provider_docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Learn more <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
)}
</div>
{currentProvider && providerSchema && configFields[0] && (
@ -501,18 +534,26 @@ export function ServiceConfigurationForm({
{currentProvider && providerSchema && configFields.length > 1 && (
<div className="grid grid-cols-2 gap-4">
{configFields.slice(1).map((field) => (
<div key={field} className="space-y-2">
<Label className="capitalize">{field.replace(/_/g, ' ')}</Label>
{renderField(service, field, providerSchema)}
</div>
))}
{configFields.slice(1).map((field) => {
const fieldSchema = providerSchema.properties[field];
const actualFieldSchema = fieldSchema?.$ref && providerSchema.$defs
? providerSchema.$defs[fieldSchema.$ref.split('/').pop() || '']
: fieldSchema;
const fullWidth = actualFieldSchema?.multiline;
return (
<div key={field} className={`space-y-2 ${fullWidth ? "col-span-2" : ""}`}>
<Label className="capitalize">{field.replace(/_/g, ' ')}</Label>
{renderField(service, field, providerSchema)}
</div>
);
})}
</div>
)}
{currentProvider && providerSchema && providerSchema.properties.api_key && (
<div className="space-y-2">
<Label>{mode === 'override' ? 'API Key (leave empty to use global)' : 'API Key(s)'}</Label>
{renderFieldDescription("api_key", providerSchema)}
{apiKeys[service].map((key, index) => (
<div key={index} className="flex gap-2">
<Input
@ -564,7 +605,40 @@ export function ServiceConfigurationForm({
);
};
const renderFieldDescription = (field: string, providerSchema: ProviderSchema) => {
const schema = providerSchema.properties[field];
if (!schema) return null;
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
if (!actualSchema?.description && !actualSchema?.docs_url) return null;
return (
<p className="text-xs text-muted-foreground">
{actualSchema?.description}{" "}
{actualSchema?.docs_url && (
<a
href={actualSchema.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Supported languages <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
);
};
const renderField = (service: ServiceSegment, field: string, providerSchema: ProviderSchema) => {
return (
<>
{renderFieldInput(service, field, providerSchema)}
{renderFieldDescription(field, providerSchema)}
</>
);
};
const renderFieldInput = (service: ServiceSegment, field: string, providerSchema: ProviderSchema) => {
const schema = providerSchema.properties[field];
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
@ -699,6 +773,19 @@ export function ServiceConfigurationForm({
);
}
if (actualSchema?.multiline) {
return (
<Textarea
rows={6}
className="font-mono text-xs"
placeholder={`Enter ${field}`}
{...register(`${service}_${field}`, {
required: service !== "embeddings" && providerSchema.required?.includes(field),
})}
/>
);
}
return (
<Input
type={actualSchema?.type === "number" ? "number" : "text"}
@ -719,6 +806,8 @@ export function ServiceConfigurationForm({
const renderOverrideToggle = (service: ServiceSegment, label: string) => {
const globalVal = (userConfig as Record<string, unknown> | null)?.[service] as Record<string, unknown> | null | undefined;
const isEnabled = enabledOverrides[service];
const globalProvider = globalVal?.provider as string | undefined;
const globalProviderSchema = globalProvider ? schemas?.[service]?.[globalProvider] : undefined;
return (
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/20 mb-4">
@ -728,7 +817,7 @@ export function ServiceConfigurationForm({
</Label>
{!isEnabled && (
<p className="text-xs text-muted-foreground">
Using global: {getGlobalSummary(globalVal)}
Using global: {getGlobalSummary(globalVal, globalProviderSchema)}
</p>
)}
</div>

View file

@ -9,9 +9,10 @@ import { Badge } from "@/components/ui/badge";
interface ToolBadgesProps {
toolUuids: string[];
onStaleUuidsDetected?: (staleUuids: string[]) => void;
mcpToolFilters?: Record<string, string[]>;
}
export function ToolBadges({ toolUuids, onStaleUuidsDetected }: ToolBadgesProps) {
export function ToolBadges({ toolUuids, onStaleUuidsDetected, mcpToolFilters }: ToolBadgesProps) {
const { tools } = useWorkflow();
const [selectedTools, setSelectedTools] = useState<ToolResponse[]>([]);
@ -50,15 +51,29 @@ export function ToolBadges({ toolUuids, onStaleUuidsDetected }: ToolBadgesProps)
return (
<div className="flex flex-wrap gap-1">
{selectedTools.map((tool) => (
<Badge
key={tool.tool_uuid}
variant="outline"
className="text-xs"
>
{tool.name}
</Badge>
))}
{selectedTools.map((tool) => {
const isMcp = tool.category === "mcp";
const enabledFns = isMcp ? (mcpToolFilters?.[tool.tool_uuid] ?? []) : [];
if (isMcp && enabledFns.length > 0) {
return enabledFns.map((fn) => (
<Badge
key={`${tool.tool_uuid}-${fn}`}
variant="outline"
className="text-xs flex items-center gap-1.5"
>
<span className="h-1.5 w-1.5 rounded-full bg-green-500 shrink-0" />
{fn}
</Badge>
));
}
return (
<Badge key={tool.tool_uuid} variant="outline" className="text-xs">
{tool.name}
</Badge>
);
})}
</div>
);
}

View file

@ -1,15 +1,20 @@
"use client";
import { ExternalLink } from "lucide-react";
import { ExternalLink, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { renderToolIcon } from "@/app/tools/config";
import { useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import type { ToolResponse } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TOOLS_INTRODUCTION_DOC_URL } from "@/constants/documentation";
import { type McpDiscoveredTool, refreshMcpTools } from "./mcpRefresh";
interface ToolSelectorProps {
value: string[];
onChange: (uuids: string[]) => void;
@ -18,6 +23,46 @@ interface ToolSelectorProps {
label?: string;
description?: string;
showLabel?: boolean;
mcpToolFilters?: Record<string, string[]>;
onMcpToolFiltersChange?: (next: Record<string, string[]>) => void;
}
function isMcp(tool: ToolResponse): boolean {
return tool.category === "mcp";
}
function discoveredOf(tool: ToolResponse): McpDiscoveredTool[] {
const def = (tool.definition ?? {}) as {
config?: { discovered_tools?: McpDiscoveredTool[] };
};
return def.config?.discovered_tools ?? [];
}
function withDiscoveredTools(
tool: ToolResponse,
discoveredTools: McpDiscoveredTool[],
): ToolResponse {
const definition =
tool.definition && typeof tool.definition === "object"
? tool.definition
: {};
const config =
"config" in definition &&
definition.config &&
typeof definition.config === "object"
? definition.config
: {};
return {
...tool,
definition: {
...definition,
config: {
...config,
discovered_tools: discoveredTools,
},
},
};
}
export function ToolSelector({
@ -28,18 +73,64 @@ export function ToolSelector({
label = "Tools",
description = "Select tools that the agent can use during the conversation.",
showLabel = true,
mcpToolFilters = {},
onMcpToolFiltersChange = () => {},
}: ToolSelectorProps) {
// Filter to only show active tools
const activeTools = tools.filter((tool) => tool.status === "active");
const workflow = useWorkflowOptional();
const activeTools = tools.filter((t) => t.status === "active");
const httpTools = activeTools.filter((t) => !isMcp(t));
const mcpTools = activeTools.filter(isMcp);
const handleToggle = (toolUuid: string, checked: boolean) => {
if (checked) {
onChange([...value, toolUuid]);
} else {
onChange(value.filter((id) => id !== toolUuid));
}
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({});
const [refreshError, setRefreshError] = useState<Record<string, string>>({});
const httpHandleToggle = (toolUuid: string, checked: boolean) => {
if (checked) onChange([...value, toolUuid]);
else onChange(value.filter((id) => id !== toolUuid));
};
const mcpFnToggle = (toolUuid: string, fnName: string, checked: boolean) => {
const current = mcpToolFilters[toolUuid] ?? [];
const nextFns = checked
? Array.from(new Set([...current, fnName]))
: current.filter((n) => n !== fnName);
const nextFilters = { ...mcpToolFilters };
if (nextFns.length > 0) nextFilters[toolUuid] = nextFns;
else delete nextFilters[toolUuid];
onMcpToolFiltersChange(nextFilters);
const hasUuid = value.includes(toolUuid);
if (nextFns.length > 0 && !hasUuid) onChange([...value, toolUuid]);
else if (nextFns.length === 0 && hasUuid)
onChange(value.filter((id) => id !== toolUuid));
};
const doRefresh = async (toolUuid: string) => {
setRefreshing((r) => ({ ...r, [toolUuid]: true }));
setRefreshError((e) => {
const n = { ...e };
delete n[toolUuid];
return n;
});
const res = await refreshMcpTools(toolUuid);
setRefreshing((r) => ({ ...r, [toolUuid]: false }));
if (res.error && res.discovered_tools.length === 0) {
setRefreshError((e) => ({ ...e, [toolUuid]: res.error as string }));
return;
}
workflow?.updateTool?.(toolUuid, (tool) =>
withDiscoveredTools(tool, res.discovered_tools),
);
};
const selectedCount =
httpTools.filter((t) => value.includes(t.tool_uuid)).length +
mcpTools.reduce(
(acc, t) => acc + (mcpToolFilters[t.tool_uuid]?.length ?? 0),
0,
);
return (
<div className="grid gap-2">
{showLabel && (
@ -48,7 +139,14 @@ export function ToolSelector({
{description && (
<Label className="text-xs text-muted-foreground">
{description}{" "}
<a href={TOOLS_INTRODUCTION_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">Learn more</a>
<a
href={TOOLS_INTRODUCTION_DOC_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Learn more
</a>
</Label>
)}
</>
@ -67,45 +165,178 @@ export function ToolSelector({
</Button>
</div>
) : (
<div className="border rounded-md divide-y">
{activeTools.map((tool) => {
const isSelected = value.includes(tool.tool_uuid);
return (
<label
key={tool.tool_uuid}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Checkbox
checked={isSelected}
disabled={disabled}
onCheckedChange={(checked) => {
handleToggle(tool.tool_uuid, checked === true);
}}
/>
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: tool.icon_color || "#3B82F6",
}}
>
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
<Tabs defaultValue="http">
<TabsList>
<TabsTrigger value="http">
HTTP &amp; Tools ({httpTools.length})
</TabsTrigger>
<TabsTrigger value="mcp">
MCP ({mcpTools.length})
</TabsTrigger>
</TabsList>
<TabsContent value="http">
<div className="border rounded-md divide-y">
{httpTools.length === 0 && (
<div className="p-3 text-sm text-muted-foreground">
No HTTP/native tools.
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{tool.name}
</span>
{tool.description && (
<span className="text-xs text-muted-foreground break-words">
{tool.description}
</span>
)}
)}
{httpTools.map((tool) => {
const isSelected = value.includes(tool.tool_uuid);
return (
<label
key={tool.tool_uuid}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Checkbox
checked={isSelected}
disabled={disabled}
onCheckedChange={(c) =>
httpHandleToggle(tool.tool_uuid, c === true)
}
/>
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: tool.icon_color || "#3B82F6",
}}
>
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{tool.name}
</span>
{tool.description && (
<span className="text-xs text-muted-foreground break-words">
{tool.description}
</span>
)}
</div>
</label>
);
})}
</div>
</TabsContent>
<TabsContent value="mcp">
<div className="border rounded-md divide-y">
{mcpTools.length === 0 && (
<div className="p-3 text-sm text-muted-foreground">
No MCP tools.
</div>
</label>
);
})}
<div className="p-2 bg-muted/30">
)}
{mcpTools.map((tool) => {
const fns = discoveredOf(tool);
const selected = mcpToolFilters[tool.tool_uuid] ?? [];
const busy = !!refreshing[tool.tool_uuid];
const err = refreshError[tool.tool_uuid];
return (
<details key={tool.tool_uuid} className="p-3">
<summary className="flex items-center gap-3 cursor-pointer list-none">
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: tool.icon_color || "#8B5CF6",
}}
>
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{tool.name}
</span>
{tool.description && (
<span className="text-xs text-muted-foreground break-words">
{tool.description}
</span>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{selected.length}/{fns.length} tools
</span>
</summary>
<div className="mt-3 pl-9 grid gap-2">
<div>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => doRefresh(tool.tool_uuid)}
>
<RefreshCw
className={`h-3 w-3 mr-2 ${busy ? "animate-spin" : ""}`}
/>
Refresh tools
</Button>
</div>
{err && (
<p className="text-xs text-destructive">{err}</p>
)}
{fns.length === 0 && !err && (
<p className="text-xs text-muted-foreground">
No tools discovered Refresh.
</p>
)}
{fns.map((fn) => {
const checked = selected.includes(fn.name);
return (
<label
key={fn.name}
className="flex items-start gap-3 cursor-pointer"
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(c) =>
mcpFnToggle(tool.tool_uuid, fn.name, c === true)
}
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium">
{fn.name}
</span>
{fn.description && (
<span className="text-xs text-muted-foreground break-words">
{fn.description}
</span>
)}
</div>
</label>
);
})}
{selected
.filter((n) => !fns.some((f) => f.name === n))
.map((n) => (
<label
key={`stale-${n}`}
className="flex items-start gap-3 cursor-pointer opacity-60"
>
<Checkbox
checked
disabled={disabled}
onCheckedChange={() =>
mcpFnToggle(tool.tool_uuid, n, false)
}
/>
<span className="text-sm line-through">
{n} (unavailable)
</span>
</label>
))}
</div>
</details>
);
})}
</div>
</TabsContent>
<div className="mt-2 p-2 bg-muted/30 rounded-md">
<Link
href="/tools"
target="_blank"
@ -115,12 +346,12 @@ export function ToolSelector({
Manage Tools
</Link>
</div>
</div>
</Tabs>
)}
{value.length > 0 && (
{selectedCount > 0 && (
<p className="text-xs text-muted-foreground">
{value.length} tool{value.length !== 1 ? "s" : ""} selected
{selectedCount} tool{selectedCount !== 1 ? "s" : ""} selected
</p>
)}
</div>

View file

@ -0,0 +1,68 @@
import { refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost } from "@/client/sdk.gen";
import type { McpRefreshResponse } from "@/client/types.gen";
export interface McpDiscoveredTool {
name: string;
description: string;
}
export interface McpRefreshResult {
tool_uuid: string;
discovered_tools: McpDiscoveredTool[];
error: string | null;
}
function normalizeDiscoveredTools(
discoveredTools: McpRefreshResponse["discovered_tools"],
): McpDiscoveredTool[] {
if (!Array.isArray(discoveredTools)) {
return [];
}
return discoveredTools.flatMap((tool) => {
if (!tool || typeof tool !== "object") {
return [];
}
const name = "name" in tool ? tool.name : undefined;
if (typeof name !== "string" || !name.trim()) {
return [];
}
const description =
"description" in tool && typeof tool.description === "string"
? tool.description
: "";
return [{ name, description }];
});
}
/**
* Re-discover an MCP tool's server catalog.
* Uses the shared generated `client` (auth bearer is injected by interceptor).
*/
export async function refreshMcpTools(
toolUuid: string,
): Promise<McpRefreshResult> {
const { data, error } = await refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost({
path: {
tool_uuid: toolUuid,
},
});
if (error || !data) {
return {
tool_uuid: toolUuid,
discovered_tools: [],
error:
typeof error === "string"
? error
: "Refresh request failed. Check the MCP server and try again.",
};
}
return {
tool_uuid: data.tool_uuid,
discovered_tools: normalizeDiscoveredTools(data.discovered_tools),
error: data.error ?? null,
};
}

View file

@ -9,8 +9,9 @@ export const BaseNode = forwardRef<
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtimeActive?: boolean;
}
>(({ className, selected, invalid, selected_through_edge, hovered_through_edge, ...props }, ref) => (
>(({ children, className, selected, invalid, selected_through_edge, hovered_through_edge, runtimeActive, ...props }, ref) => (
<div
ref={ref}
className={cn(
@ -26,11 +27,14 @@ export const BaseNode = forwardRef<
// Hovered through edge takes precedence over selected through edge
hovered_through_edge ? "ring-2 ring-primary/60 shadow-[0_0_12px_rgba(96,165,250,0.3)]" : "",
!hovered_through_edge && selected_through_edge ? "ring-1 ring-primary/50 shadow-[0_0_8px_rgba(59,130,246,0.2)]" : "",
runtimeActive ? "ring-2 ring-sky-400/60 shadow-[0_0_0_1px_rgba(56,189,248,0.18),0_0_24px_rgba(14,165,233,0.18)]" : "",
!selected_through_edge && !hovered_through_edge && "hover:border-muted-foreground/50",
)}
tabIndex={0}
{...props}
/>
>
{children}
</div>
));
BaseNode.displayName = "BaseNode";

View file

@ -31,7 +31,8 @@ type NodeStyleVariant =
| "global"
| "trigger"
| "webhook"
| "qa";
| "qa"
| "integration";
const STYLE_VARIANT_BY_SPEC: Record<string, NodeStyleVariant> = {
startCall: "start",
@ -100,6 +101,70 @@ function buildTriggerEndpoints(
};
}
function resolveIntegrationEnabled(
spec: NodeSpec,
data: FlowNodeData,
): boolean {
for (const prop of spec.properties) {
if (!prop.name.endsWith("enabled")) continue;
const value = data[prop.name];
if (typeof value === "boolean") return value;
}
return true;
}
function resolveIntegrationSummary(
spec: NodeSpec,
data: FlowNodeData,
): string {
for (const prop of spec.properties) {
if (
prop.name === "name" ||
prop.name.endsWith("enabled") ||
/api[_-]?key|token|secret/i.test(prop.name)
) {
continue;
}
const value = data[prop.name];
if (typeof value === "string" && value.trim().length > 0) {
return value.length > 30 ? `${value.slice(0, 30)}...` : value;
}
if (typeof value === "number") {
return String(value);
}
}
return "Not configured";
}
function getBadgeForSpec(
spec: NodeSpec | undefined,
variant: NodeStyleVariant,
): { label: string; className: string } {
if (!spec) {
return { label: "Node", className: "bg-zinc-500 text-white" };
}
switch (variant) {
case "start":
return { label: "Start Node", className: "bg-emerald-500 text-white" };
case "agent":
return { label: "Agent Node", className: "bg-blue-500 text-white" };
case "end":
return { label: "End Node", className: "bg-rose-500 text-white" };
case "global":
return { label: "Global Node", className: "bg-amber-500 text-white" };
case "trigger":
return { label: "API Trigger", className: "bg-purple-500 text-white" };
case "webhook":
return { label: "Webhook", className: "bg-indigo-500 text-white" };
case "qa":
return { label: "QA Analysis", className: "bg-teal-500 text-white" };
case "integration":
return { label: spec.display_name, className: "bg-cyan-600 text-white" };
}
}
// ─── Canvas preview dispatch ──────────────────────────────────────────────
function CanvasPreview({
@ -188,6 +253,21 @@ function CanvasPreview({
);
}
if (spec.category === "integration") {
const enabled = resolveIntegrationEnabled(spec, data);
const destination = resolveIntegrationSummary(spec, data);
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{destination}
</span>
</div>
<StatusDot enabled={enabled} />
</div>
);
}
// Default: prompt preview + tool/document badges (when spec declares them).
const hasToolRefs = spec.properties.some((p) => p.type === "tool_refs");
const hasDocRefs = spec.properties.some((p) => p.type === "document_refs");
@ -205,6 +285,7 @@ function CanvasPreview({
<ToolBadges
toolUuids={data.tool_uuids}
onStaleUuidsDetected={onStaleTools}
mcpToolFilters={data.mcp_tool_filters}
/>
</div>
)}
@ -396,14 +477,22 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
const spec = bySpecName.get(type);
// ── Form state ─────────────────────────────────────────────────────
const [values, setValues] = useState<Record<string, unknown>>(() =>
spec ? seedValues(data, spec) : {},
// mcp_tool_filters is not a spec property, so seedValues won't carry it;
// seed merges it back in alongside the spec-derived values.
const seed = useCallback(
() =>
spec
? { ...seedValues(data, spec), mcp_tool_filters: data.mcp_tool_filters }
: {},
[data, spec],
);
const [values, setValues] = useState<Record<string, unknown>>(seed);
// Re-seed once the spec arrives (initial fetch race).
useEffect(() => {
if (spec && Object.keys(values).length === 0) {
setValues(seedValues(data, spec));
setValues(seed());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spec]);
@ -464,7 +553,11 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
const isDirty = useMemo(() => {
if (!spec) return false;
const baseline = seedValues(data, spec);
return propertyNames.some((n) => values[n] !== baseline[n]);
if (propertyNames.some((n) => values[n] !== baseline[n])) return true;
return (
JSON.stringify(values.mcp_tool_filters ?? {}) !==
JSON.stringify(data.mcp_tool_filters ?? {})
);
}, [values, data, spec, propertyNames]);
const handleSave = async () => {
@ -478,20 +571,30 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
};
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && spec) setValues(seedValues(data, spec));
if (newOpen && spec) setValues(seed());
setOpen(newOpen);
};
useEffect(() => {
if (open && spec) setValues(seedValues(data, spec));
if (open && spec) setValues(seed());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, open]);
// ── Render ──────────────────────────────────────────────────────────
const styleVariant = STYLE_VARIANT_BY_SPEC[type];
const handles = HANDLES_BY_SPEC[type] ?? { source: true, target: true };
const styleVariant =
STYLE_VARIANT_BY_SPEC[type] ??
(spec?.category === "integration" ? "integration" : "agent");
const handles =
HANDLES_BY_SPEC[type] ??
(spec?.category === "integration"
? { source: false, target: false }
: { source: true, target: true });
const badge = getBadgeForSpec(spec, styleVariant);
const Icon = spec ? resolveIcon(spec.icon) : Circle;
const docUrl = DOC_URL_BY_SPEC[type];
const contentLabel = spec?.properties.some((p) => p.name === "prompt")
? "Prompt"
: "Details";
// Edit dialog title: "Edit {display_name}". Webhook keeps the original
// "Edit Webhook" wording — display_name is "Webhook" so it works out.
@ -505,9 +608,12 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
runtimeActive={data.runtime_active}
title={data.name || fallbackTitle}
icon={<Icon />}
nodeType={styleVariant}
badgeLabel={badge.label}
badgeClassName={badge.className}
contentLabel={contentLabel}
hasSourceHandle={handles.source}
hasTargetHandle={handles.target}
onDoubleClick={() => setOpen(true)}
@ -562,6 +668,18 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
tools: tools ?? [],
documents: documents ?? [],
recordings: recordings ?? [],
mcpToolFilters:
(values.mcp_tool_filters as
| Record<string, string[]>
| undefined) ?? {},
onMcpToolFiltersChange: (next) =>
setValues((prev) => ({
...prev,
mcp_tool_filters:
Object.keys(next).length > 0
? next
: undefined,
})),
}}
/>
{type === "trigger" && (

View file

@ -10,9 +10,12 @@ interface NodeContentProps {
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtimeActive?: boolean;
title: string;
icon: ReactNode;
nodeType?: 'start' | 'agent' | 'end' | 'global' | 'trigger' | 'webhook' | 'qa';
badgeLabel?: string;
badgeClassName?: string;
contentLabel?: string;
hasSourceHandle?: boolean;
hasTargetHandle?: boolean;
children?: ReactNode;
@ -22,35 +25,19 @@ interface NodeContentProps {
}
// Get badge styling based on node type
const getNodeTypeBadge = (nodeType?: string) => {
switch (nodeType) {
case 'start':
return { label: 'Start Node', className: 'bg-emerald-500 text-white' };
case 'agent':
return { label: 'Agent Node', className: 'bg-blue-500 text-white' };
case 'end':
return { label: 'End Node', className: 'bg-rose-500 text-white' };
case 'global':
return { label: 'Global Node', className: 'bg-amber-500 text-white' };
case 'trigger':
return { label: 'API Trigger', className: 'bg-purple-500 text-white' };
case 'webhook':
return { label: 'Webhook', className: 'bg-indigo-500 text-white' };
case 'qa':
return { label: 'QA Analysis', className: 'bg-teal-500 text-white' };
default:
return { label: 'Node', className: 'bg-zinc-500 text-white' };
}
};
const DEFAULT_BADGE = { label: 'Node', className: 'bg-zinc-500 text-white' };
export const NodeContent = ({
selected,
invalid,
selected_through_edge,
hovered_through_edge,
runtimeActive,
title,
icon,
nodeType,
badgeLabel,
badgeClassName,
contentLabel = "Prompt",
hasSourceHandle = false,
hasTargetHandle = false,
children,
@ -58,7 +45,10 @@ export const NodeContent = ({
onDoubleClick,
nodeId,
}: NodeContentProps) => {
const badge = getNodeTypeBadge(nodeType);
const badge = {
label: badgeLabel ?? DEFAULT_BADGE.label,
className: badgeClassName ?? DEFAULT_BADGE.className,
};
return (
<BaseNode
@ -66,6 +56,7 @@ export const NodeContent = ({
invalid={invalid}
selected_through_edge={selected_through_edge}
hovered_through_edge={hovered_through_edge}
runtimeActive={runtimeActive}
className={`p-0 ${className}`}
onDoubleClick={onDoubleClick}
>
@ -98,7 +89,9 @@ export const NodeContent = ({
{/* Content area with prompt label */}
<div className="p-4">
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Prompt:</div>
<div className="text-xs text-muted-foreground mb-1.5 font-medium">
{contentLabel}:
</div>
{children}
</div>

View file

@ -23,6 +23,10 @@ export interface RendererContext {
tools: ToolResponse[];
documents: DocumentResponseSchema[];
recordings: RecordingResponseSchema[];
/** Per-node MCP function allowlist (sibling of tool_uuids on node data). */
mcpToolFilters?: Record<string, string[]>;
/** Persist a new mcp_tool_filters object onto the node form values. */
onMcpToolFiltersChange?: (next: Record<string, string[]>) => void;
}
export interface PropertyInputProps {
@ -83,6 +87,10 @@ export function PropertyInput({ spec, value, onChange, context }: PropertyInputP
value={value}
onChange={onChange}
tools={context.tools}
mcpToolFilters={context.mcpToolFilters ?? {}}
onMcpToolFiltersChange={
context.onMcpToolFiltersChange ?? (() => {})
}
/>
);
case "document_refs":
@ -401,7 +409,13 @@ function ToolRefsWidget({
value,
onChange,
tools,
}: WidgetProps & { tools: ToolResponse[] }) {
mcpToolFilters,
onMcpToolFiltersChange,
}: WidgetProps & {
tools: ToolResponse[];
mcpToolFilters: Record<string, string[]>;
onMcpToolFiltersChange: (next: Record<string, string[]>) => void;
}) {
return (
<ToolSelector
value={(value as string[] | undefined) ?? []}
@ -409,6 +423,8 @@ function ToolRefsWidget({
tools={tools}
label={spec.display_name}
description={spec.description}
mcpToolFilters={mcpToolFilters}
onMcpToolFiltersChange={onMcpToolFiltersChange}
/>
);
}

View file

@ -5,19 +5,19 @@ export enum NodeType {
GLOBAL_NODE = 'globalNode',
TRIGGER = 'trigger',
WEBHOOK = 'webhook',
QA = 'qa'
QA = 'qa',
}
export type FlowNodeData = {
prompt: string;
prompt?: string;
name: string;
is_start?: boolean;
is_static?: boolean;
is_end?: boolean;
invalid?: boolean;
validationMessage?: string | null;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtime_active?: boolean;
allow_interrupt?: boolean;
extraction_enabled?: boolean;
extraction_prompt?: string;
@ -26,8 +26,6 @@ export type FlowNodeData = {
greeting?: string;
greeting_type?: 'text' | 'audio';
greeting_recording_id?: string;
wait_for_user_greeting?: boolean;
detect_voicemail?: boolean;
delayed_start?: boolean;
delayed_start_duration?: number;
// Pre-call data fetch (StartCall only)
@ -55,8 +53,13 @@ export type FlowNodeData = {
qa_sample_rate?: number;
// Tools - array of tool UUIDs that can be invoked by this node
tool_uuids?: string[];
// Per-node MCP function allowlist: { toolUuid: [raw MCP tool name, ...] }.
// Default-none: a toolUuid absent here (or mapped to []) exposes zero
// functions of that MCP server on this node.
mcp_tool_filters?: Record<string, string[]>;
// Documents - array of knowledge base document UUIDs that can be referenced by this node
document_uuids?: string[];
[key: string]: unknown;
}
export type FlowNode = {
@ -131,4 +134,3 @@ export interface Credential {
created_at: string;
updated_at: string;
}

View file

@ -2,5 +2,11 @@ export { CreateCredentialDialog } from "./create-credential-dialog";
export { CredentialSelector } from "./credential-selector";
export { type HttpMethod, HttpMethodSelector } from "./http-method-selector";
export { KeyValueEditor, type KeyValueItem } from "./key-value-editor";
export { ParameterEditor, type ParameterType,type ToolParameter } from "./parameter-editor";
export { UrlInput, type UrlValidationResult,validateUrl } from "./url-input";
export {
ParameterEditor,
type ParameterType,
PresetParameterEditor,
type PresetToolParameter,
type ToolParameter,
} from "./parameter-editor";
export { UrlInput, type UrlValidationResult, validateUrl } from "./url-input";

View file

@ -23,6 +23,13 @@ export interface ToolParameter {
required: boolean;
}
export interface PresetToolParameter {
name: string;
type: ParameterType;
valueTemplate: string;
required: boolean;
}
interface ParameterEditorProps {
parameters: ToolParameter[];
onChange: (parameters: ToolParameter[]) => void;
@ -165,3 +172,146 @@ export function ParameterEditor({
</div>
);
}
interface PresetParameterEditorProps {
parameters: PresetToolParameter[];
onChange: (parameters: PresetToolParameter[]) => void;
disabled?: boolean;
}
export function PresetParameterEditor({
parameters,
onChange,
disabled = false,
}: PresetParameterEditorProps) {
const addParameter = () => {
onChange([
...parameters,
{ name: "", type: "string", valueTemplate: "", required: true },
]);
};
const updateParameter = (
index: number,
field: keyof PresetToolParameter,
value: string | boolean
) => {
const newParams = [...parameters];
newParams[index] = { ...newParams[index], [field]: value };
onChange(newParams);
};
const removeParameter = (index: number) => {
onChange(parameters.filter((_, i) => i !== index));
};
return (
<div className="space-y-4">
{parameters.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-md">
No preset parameters defined. Add one to inject a fixed value or workflow context into the request.
</div>
)}
{parameters.map((param, index) => (
<div
key={index}
className="border rounded-lg p-4 space-y-3 bg-muted/20"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Preset Parameter {index + 1}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => removeParameter(index)}
disabled={disabled}
className="h-8 w-8"
>
<Trash2Icon className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Label className="text-xs text-muted-foreground">
Key sent to the API, like &quot;phone_number&quot; or &quot;customer_id&quot;
</Label>
<Input
placeholder="e.g., phone_number"
value={param.name}
onChange={(e) =>
updateParameter(index, "name", e.target.value)
}
disabled={disabled}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<Label className="text-xs text-muted-foreground">
JSON type to send to the API
</Label>
<Select
value={param.type}
onValueChange={(value: ParameterType) =>
updateParameter(index, "type", value)
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">String</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Value or Template</Label>
<Label className="text-xs text-muted-foreground">
Use a fixed value or a template like {`{{initial_context.phone_number}}`} or {`{{gathered_context.customer_id}}`}
</Label>
<Input
placeholder="e.g., {{initial_context.phone_number}}"
value={param.valueTemplate}
onChange={(e) =>
updateParameter(index, "valueTemplate", e.target.value)
}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-2">
<Switch
id={`preset-required-${index}`}
checked={param.required}
onCheckedChange={(checked) =>
updateParameter(index, "required", checked)
}
disabled={disabled}
/>
<Label htmlFor={`preset-required-${index}`} className="text-sm">
Required
</Label>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addParameter}
className="w-fit"
disabled={disabled}
>
<PlusIcon className="h-4 w-4 mr-1" /> Add Preset Parameter
</Button>
</div>
);
}

View file

@ -62,15 +62,13 @@ const AppLayout: React.FC<AppLayoutProps> = ({
// Hide sidebar for root (/), /handler routes (Stack Auth routes), and /auth routes
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler") && !pathname.startsWith("/auth");
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
// Only match the exact editor page /workflow/<id>, not sub-routes like /workflow/<id>/runs
const isWorkflowEditor = /^\/workflow\/\d+$/.test(pathname);
const isSuperadmin = pathname.startsWith("/superadmin");
// Always render SidebarProvider to keep the component tree shape consistent
// across route changes (avoids React hooks ordering violations during navigation).
return (
<SidebarProvider defaultOpen={!isWorkflowEditor && !isSuperadmin}>
<SidebarProvider defaultOpen>
{shouldShowSidebar ? (
<div className="flex min-h-screen w-full">
<AppSidebar />

View file

@ -14,6 +14,7 @@ import {
Home,
Key,
LogOut,
type LucideIcon,
Megaphone,
Phone,
Settings,
@ -49,12 +50,7 @@ import {
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppConfig } from "@/context/AppConfigContext";
import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext";
import { useLatestReleaseVersion } from "@/hooks/useLatestReleaseVersion";
@ -62,6 +58,93 @@ import type { LocalUser } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils";
type SidebarNavItem = {
title: string;
url: string;
icon: LucideIcon;
showsTelephonyWarning?: boolean;
};
type SidebarNavSection = {
label?: string;
items: SidebarNavItem[];
};
const TELEPHONY_WARNING_COPY = "Action required";
const NAV_SECTIONS: SidebarNavSection[] = [
{
items: [
{
title: "Overview",
url: "/overview",
icon: Home,
},
],
},
{
label: "BUILD",
items: [
{
title: "Voice Agents",
url: "/workflow",
icon: Workflow,
},
{
title: "Campaigns",
url: "/campaigns",
icon: Megaphone,
},
{
title: "Models",
url: "/model-configurations",
icon: Brain,
},
{
title: "Telephony",
url: "/telephony-configurations",
icon: Phone,
showsTelephonyWarning: true,
},
{
title: "Tools",
url: "/tools",
icon: Wrench,
},
{
title: "Files",
url: "/files",
icon: Database,
},
{
title: "Recordings",
url: "/recordings",
icon: AudioLines,
},
{
title: "Developers",
url: "/api-keys",
icon: Key,
},
],
},
{
label: "OBSERVE",
items: [
{
title: "Agent Runs",
url: "/usage",
icon: TrendingUp,
},
{
title: "Reports",
url: "/reports",
icon: FileText,
},
],
},
];
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({
@ -77,10 +160,7 @@ export function AppSidebar() {
const { config } = useAppConfig();
const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings();
const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 0;
// On mobile the sidebar renders as a full-width sheet overlay, so treat it
// as always "expanded" regardless of the desktop collapsed/expanded state.
const effectiveState = isMobile ? "expanded" : state;
const isCollapsed = !isMobile && state === "collapsed";
// Get selected team for Stack auth (cast to Team type from Stack)
// Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes,
@ -101,90 +181,7 @@ export function AppSidebar() {
{ enabled: config?.deploymentMode === "oss" },
);
const isActive = (path: string) => {
return pathname.startsWith(path);
};
// Organize navigation into sections
const overviewSection = [
{
title: "Overview",
url: "/overview",
icon: Home,
},
];
const buildSection = [
{
title: "Voice Agents",
url: "/workflow",
icon: Workflow,
},
{
title: "Campaigns",
url: "/campaigns",
icon: Megaphone,
},
// {
// title: "Automation",
// url: "/automation",
// icon: Zap,
// },
{
title: "Models",
url: "/model-configurations",
icon: Brain,
},
{
title: "Telephony",
url: "/telephony-configurations",
icon: Phone,
},
{
title: "Tools",
url: "/tools",
icon: Wrench,
},
{
title: "Files",
url: "/files",
icon: Database,
},
{
title: "Recordings",
url: "/recordings",
icon: AudioLines,
},
// {
// title: "Integrations",
// url: "/integrations",
// icon: Plug,
// },
{
title: "Developers",
url: "/api-keys",
icon: Key,
},
];
const observeSection = [
{
title: "Agent Runs",
url: "/usage",
icon: TrendingUp,
},
{
title: "Reports",
url: "/reports",
icon: FileText,
},
// {
// title: "LoopTalk",
// url: "/looptalk",
// icon: MessageSquare,
// },
];
const isActive = (path: string) => pathname.startsWith(path);
const handleMobileNavClick = () => {
if (isMobile) {
@ -192,79 +189,65 @@ export function AppSidebar() {
}
};
const SidebarLink = ({ item }: { item: typeof overviewSection[0] }) => {
const SidebarLink = ({ item }: { item: SidebarNavItem }) => {
const isItemActive = isActive(item.url);
const Icon = item.icon;
const showWarningDot =
item.url === "/telephony-configurations" && hasTelephonyWarning;
if (effectiveState === "collapsed") {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
asChild
className={cn(
"hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-accent text-accent-foreground"
)}
>
<Link href={item.url} onClick={handleMobileNavClick} className="relative">
<Icon className="h-4 w-4" />
{showWarningDot && (
<AlertTriangle
aria-hidden
className="absolute -right-0.5 -top-0.5 h-3 w-3 text-amber-500"
/>
)}
<span className="sr-only">
{item.title}
{showWarningDot && " — action required before 15 May 2026"}
</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>
{item.title}
{showWarningDot && (
<span className="block text-amber-600 dark:text-amber-400">
Action required before 15 May 2026
</span>
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const showWarningDot = item.showsTelephonyWarning && hasTelephonyWarning;
const tooltip = {
children: (
<div className="notranslate" translate="no">
<p>{item.title}</p>
{showWarningDot && (
<p className="text-amber-600 dark:text-amber-400">{TELEPHONY_WARNING_COPY}</p>
)}
</div>
),
};
const warningIndicator = (
<AlertTriangle
aria-label="Action required on a telephony configuration"
className={cn(
"text-amber-500",
isCollapsed ? "absolute -right-0.5 -top-0.5 h-3 w-3" : "ml-auto h-3.5 w-3.5"
)}
/>
);
return (
<SidebarMenuButton
asChild
tooltip={tooltip}
className={cn(
"hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-accent text-accent-foreground"
)}
>
<Link href={item.url} onClick={handleMobileNavClick}>
<Icon className="h-4 w-4" />
<span>{item.title}</span>
<Link
href={item.url}
onClick={handleMobileNavClick}
className={cn("relative", isCollapsed && "justify-center")}
translate="no"
>
<Icon className="h-4 w-4 shrink-0" />
<span
className={cn("notranslate min-w-0 flex-1 truncate", isCollapsed && "sr-only")}
translate="no"
>
{item.title}
</span>
{showWarningDot && (
<TooltipProvider delayDuration={0}>
isCollapsed ? (
warningIndicator
) : (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle
aria-label="Action required on a telephony configuration before 15 May 2026"
className="ml-auto h-3.5 w-3.5 text-amber-500"
/>
{warningIndicator}
</TooltipTrigger>
<TooltipContent side="right">
<p>Action required before 15 May 2026</p>
<p>{TELEPHONY_WARNING_COPY}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
</Link>
</SidebarMenuButton>
@ -273,77 +256,70 @@ export function AppSidebar() {
return (
<Sidebar collapsible="icon" className="border-r">
<SidebarHeader className="border-b px-2 py-3">
<SidebarHeader className="border-b px-2 py-3 notranslate" translate="no">
<div className="flex items-center justify-between">
{/* Logo - only show when expanded */}
{effectiveState === "expanded" && (
<div className="flex items-center gap-2">
<Link
href="/"
className="flex items-center gap-2 px-2 text-xl font-bold"
>
Dograh
{versionInfo && (
<span className="text-xs font-normal text-muted-foreground">
v{versionInfo.ui}
<div className={cn("flex items-center gap-2", isCollapsed && "hidden")}>
<Link
href="/"
className="notranslate flex items-center gap-2 px-2 text-xl font-bold"
translate="no"
>
Dograh
{versionInfo && (
<span
className="notranslate text-xs font-normal text-muted-foreground"
translate="no"
>
v{versionInfo.ui}
</span>
)}
</Link>
{isBehind && latestRelease && (
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://docs.dograh.com/deployment/update"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-md border bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-900 transition-opacity hover:opacity-80 dark:bg-amber-950 dark:text-amber-200"
>
<ArrowUpCircle className="h-3 w-3" />
Update
</a>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Latest: {latestRelease} click to see the update guide</p>
</TooltipContent>
</Tooltip>
)}
{isLatest && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center rounded-md border bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">
Latest
</span>
)}
</Link>
{isBehind && latestRelease && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://docs.dograh.com/deployment/update"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-md border bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-900 transition-opacity hover:opacity-80 dark:bg-amber-950 dark:text-amber-200"
>
<ArrowUpCircle className="h-3 w-3" />
Update
</a>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Latest: {latestRelease} click to see the update guide</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isLatest && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center rounded-md border bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">
Latest
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>You&apos;re running the latest release</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
{/* Toggle button - center it when collapsed */}
<SidebarTrigger className={cn(
"hover:bg-accent",
effectiveState === "collapsed" && "mx-auto"
)}>
{effectiveState === "expanded" ? (
<ChevronLeft className="h-4 w-4" />
) : (
</TooltipTrigger>
<TooltipContent side="bottom">
<p>You&apos;re running the latest release</p>
</TooltipContent>
</Tooltip>
)}
</div>
<SidebarTrigger className={cn("hover:bg-accent", isCollapsed && "mx-auto")}>
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</SidebarTrigger>
</div>
{/* Team Switcher for Stack Auth - at the top */}
{provider === "stack" && effectiveState === "expanded" && (
<div className="mt-3">
{provider === "stack" && (
<div className={cn("mt-3 notranslate", isCollapsed && "hidden")} translate="no">
<React.Suspense
fallback={
<div className="h-9 w-full animate-pulse bg-muted rounded" />
<div className="h-9 w-full animate-pulse rounded bg-muted" />
}
>
<StackTeamSwitcher
@ -355,73 +331,46 @@ export function AppSidebar() {
</React.Suspense>
</div>
)}
</SidebarHeader>
<SidebarContent className={cn(
effectiveState === "collapsed" && "px-0"
)}>
{/* Overview Section */}
<SidebarGroup className="mt-2">
<SidebarMenu>
{overviewSection.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarLink item={item} />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
{/* BUILD Section */}
{buildSection.length > 0 && (
<SidebarGroup className="mt-6">
{effectiveState === "expanded" && (
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
BUILD
<SidebarContent className={cn("notranslate", isCollapsed && "px-0")} translate="no">
{NAV_SECTIONS.map((section, index) => (
<SidebarGroup
key={section.label ?? "overview"}
className={index === 0 ? "mt-2" : "mt-6"}
>
{section.label && (
<SidebarGroupLabel
className={cn(
"notranslate text-xs font-semibold uppercase tracking-wider text-muted-foreground",
isCollapsed && "hidden"
)}
translate="no"
>
{section.label}
</SidebarGroupLabel>
)}
<SidebarMenu>
{buildSection.map((item) => (
{section.items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarLink item={item} />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
)}
{/* OBSERVE Section */}
<SidebarGroup className="mt-6">
{effectiveState === "expanded" && (
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
OBSERVE
</SidebarGroupLabel>
)}
<SidebarMenu>
{observeSection.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarLink item={item} />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter className={cn(
"border-t p-4",
effectiveState === "collapsed" && "p-2"
)}>
{/* Bottom Actions */}
<SidebarFooter
className={cn("border-t p-4 notranslate", isCollapsed && "p-2")}
translate="no"
>
<div className="space-y-2">
{/* User Button - for local/OSS mode */}
{provider !== "stack" && (
<div className={cn(
"flex",
effectiveState === "collapsed" ? "justify-center" : "justify-start"
)}>
<div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full h-8 w-8 cursor-pointer">
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
<span className="text-xs font-medium">
{(user?.displayName || (user as LocalUser | undefined)?.email || "")
.split(/[\s@]/)
@ -455,15 +404,11 @@ export function AppSidebar() {
</div>
)}
{/* User Button - for Stack auth */}
{provider === "stack" && (
<div className={cn(
"flex",
effectiveState === "collapsed" ? "justify-center" : "justify-start"
)}>
<div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full h-8 w-8 cursor-pointer">
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
<span className="text-xs font-medium">
{(user?.displayName || (user as { primaryEmail?: string })?.primaryEmail || "")
.split(/[\s@]/)
@ -508,35 +453,30 @@ export function AppSidebar() {
</div>
)}
{/* Theme Toggle - at the very bottom */}
<div className={cn(
"mt-2 pt-2 border-t",
effectiveState === "collapsed" ? "flex justify-center" : ""
)}>
{effectiveState === "collapsed" ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<ThemeToggle
showLabel={false}
className="hover:bg-accent hover:text-accent-foreground"
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Toggle theme</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className={cn("mt-2 border-t pt-2", isCollapsed && "flex justify-center")}>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="notranslate" translate="no">
<ThemeToggle
showLabel={false}
className="hover:bg-accent hover:text-accent-foreground"
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Toggle theme</p>
</TooltipContent>
</Tooltip>
) : (
<ThemeToggle
showLabel={true}
className="hover:bg-accent hover:text-accent-foreground"
/>
<div className="notranslate" translate="no">
<ThemeToggle
showLabel={true}
className="hover:bg-accent hover:text-accent-foreground"
/>
</div>
)}
</div>
</div>
</SidebarFooter>
<SidebarRail />

View file

@ -1,126 +0,0 @@
'use client';
import { format } from 'date-fns';
import { useEffect, useState } from 'react';
import { getTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGet } from '@/client/sdk.gen';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { Conversation } from './types';
interface ConversationsListProps {
testSessionId: number;
}
export function ConversationsList({ testSessionId }: ConversationsListProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth();
useEffect(() => {
const fetchConversations = async () => {
if (!user) return;
try {
const response = await getTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGet({
path: {
test_session_id: testSessionId
},
});
// API returns { conversation: Conversation | null }
const responseData = response.data as { conversation: Conversation | null } | null;
if (responseData?.conversation) {
setConversations([responseData.conversation]);
} else {
setConversations([]);
}
} catch (err) {
logger.error('Error fetching conversations:', err);
setError('Failed to load conversations');
} finally {
setLoading(false);
}
};
fetchConversations();
// Poll for updates every 5 seconds
const interval = setInterval(fetchConversations, 5000);
return () => clearInterval(interval);
}, [testSessionId, user]);
if (loading && conversations.length === 0) {
return (
<div className="space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} className="h-24 bg-gray-200 animate-pulse" />
))}
</div>
);
}
if (error) {
return (
<div className="text-red-500">
{error}
</div>
);
}
if (conversations.length === 0) {
return (
<Card>
<CardContent className="text-center py-8">
<div className="text-gray-500 mb-2">
No conversations started yet
</div>
<p className="text-sm text-gray-400">
Start the test session to begin agent conversations
</p>
</CardContent>
</Card>
);
}
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'active':
return 'default';
case 'completed':
return 'secondary';
case 'failed':
return 'destructive';
default:
return 'outline';
}
};
return (
<div className="space-y-4">
{conversations.map((conversation) => (
<Card key={conversation.id}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-lg">
Conversation {conversation.conversation_pair_id || conversation.id}
</CardTitle>
<CardDescription>
Started: {format(new Date(conversation.created_at), 'h:mm:ss a')}
</CardDescription>
</div>
<Badge variant={getStatusBadgeVariant(conversation.status)}>
{conversation.status}
</Badge>
</div>
</CardHeader>
</Card>
))}
</div>
);
}

View file

@ -1,177 +0,0 @@
'use client';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { createTestSessionApiV1LooptalkTestSessionsPost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
export function CreateTestSessionButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { user } = useAuth();
const [formData, setFormData] = useState({
name: '',
description: '',
test_type: 'single',
actor_workflow_id: '',
adversary_workflow_id: '',
concurrent_pairs: 1,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (!user) return;
const response = await createTestSessionApiV1LooptalkTestSessionsPost({
body: {
name: formData.name,
actor_workflow_id: parseInt(formData.actor_workflow_id),
adversary_workflow_id: parseInt(formData.adversary_workflow_id),
config: {
test_type: formData.test_type,
description: formData.description,
concurrent_pairs: formData.test_type === 'load_test' ? formData.concurrent_pairs : undefined
}
},
});
toast.success('Test session created successfully');
setOpen(false);
if (response.data?.id) {
router.push(`/looptalk/${response.data.id}`);
} else {
router.push('/looptalk');
}
} catch (error) {
logger.error('Error creating test session:', error);
toast.error('Failed to create test session');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
New Test Session
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[525px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Create Test Session</DialogTitle>
<DialogDescription>
Set up a new LoopTalk test session to test conversations between agents.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Test Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Test Session"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description of the test"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="test_type">Test Type</Label>
<Select
value={formData.test_type}
onValueChange={(value) => setFormData({ ...formData, test_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">Single Test</SelectItem>
{/* <SelectItem value="load_test">Load Test</SelectItem> */}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="actor_workflow">Actor Workflow ID</Label>
<Input
id="actor_workflow"
type="number"
value={formData.actor_workflow_id}
onChange={(e) => setFormData({ ...formData, actor_workflow_id: e.target.value })}
placeholder="Enter workflow ID"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="adversary_workflow">Adversary Workflow ID</Label>
<Input
id="adversary_workflow"
type="number"
value={formData.adversary_workflow_id}
onChange={(e) => setFormData({ ...formData, adversary_workflow_id: e.target.value })}
placeholder="Enter workflow ID"
required
/>
</div>
{formData.test_type === 'load_test' && (
<div className="grid gap-2">
<Label htmlFor="concurrent_pairs">Concurrent Pairs</Label>
<Input
id="concurrent_pairs"
type="number"
min="1"
max="10"
value={formData.concurrent_pairs}
onChange={(e) => setFormData({ ...formData, concurrent_pairs: parseInt(e.target.value) || 1 })}
required
/>
</div>
)}
</div>
<DialogFooter>
<Button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Test Session'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -1,370 +0,0 @@
'use client';
import { Pause, Play, Volume2, VolumeX } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
interface LiveAudioPlayerProps {
testSessionId: number;
sessionStatus: 'pending' | 'running' | 'completed' | 'failed';
autoStart?: boolean;
}
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
type AudioRole = 'mixed' | 'actor' | 'adversary';
export function LiveAudioPlayer({
testSessionId,
sessionStatus,
autoStart = false
}: LiveAudioPlayerProps) {
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
const [audioRole, setAudioRole] = useState<AudioRole>(() => {
// Load saved preference from localStorage
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('looptalk-audio-role');
return (saved as AudioRole) || 'mixed';
}
return 'mixed';
});
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(0.8);
const [bufferedDuration, setBufferedDuration] = useState(0);
const [audioLevel, setAudioLevel] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioQueueRef = useRef<AudioBufferSourceNode[]>([]);
const nextStartTimeRef = useRef(0);
const animationFrameRef = useRef<number | undefined>(undefined);
const isConnectingRef = useRef(false);
const { user, getAccessToken } = useAuth();
// Auto-start streaming when session starts
useEffect(() => {
if (sessionStatus === 'running' && autoStart && !isPlaying) {
setIsPlaying(true);
}
}, [sessionStatus, autoStart, isPlaying]);
// Save audio role preference
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('looptalk-audio-role', audioRole);
}
}, [audioRole]);
// Audio level monitoring
const monitorAudioLevel = useCallback(() => {
if (!analyserRef.current) return;
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
analyserRef.current.getByteFrequencyData(dataArray);
// Calculate average level
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
setAudioLevel(average / 255); // Normalize to 0-1
animationFrameRef.current = requestAnimationFrame(monitorAudioLevel);
}, []);
const connectWebSocket = useCallback(async () => {
// Check if already connected or connecting
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
logger.debug('WebSocket already connected or connecting, skipping');
return;
}
// Prevent multiple concurrent connection attempts
if (isConnectingRef.current) {
logger.debug('Already attempting to connect, skipping');
return;
}
isConnectingRef.current = true;
try {
setConnectionStatus('connecting');
if (!user) return;
// Get auth token
const accessToken = await getAccessToken();
const httpBase = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
const baseUrl = httpBase.replace(/^http/, 'ws');
const wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// Create AudioContext with gain control and analyser
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
gainNodeRef.current = audioContextRef.current.createGain();
analyserRef.current = audioContextRef.current.createAnalyser();
analyserRef.current.fftSize = 256;
// Connect gain -> analyser -> destination
gainNodeRef.current.connect(analyserRef.current);
analyserRef.current.connect(audioContextRef.current.destination);
// Set initial volume
gainNodeRef.current.gain.value = volume;
}
ws.onopen = () => {
setConnectionStatus('connected');
logger.info('Audio stream connected');
monitorAudioLevel();
};
ws.onmessage = async (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'audio' && data.audio) {
// Decode base64 audio data
const audioBytes = Uint8Array.from(atob(data.audio), c => c.charCodeAt(0));
// Create audio buffer from PCM data
const samplesPerChannel = audioBytes.length / (data.num_channels * 2);
const audioBuffer = audioContextRef.current!.createBuffer(
data.num_channels,
samplesPerChannel,
data.sample_rate
);
// Convert PCM to float samples
const dataView = new DataView(audioBytes.buffer);
for (let channel = 0; channel < data.num_channels; channel++) {
const channelData = audioBuffer.getChannelData(channel);
for (let i = 0; i < samplesPerChannel; i++) {
const sampleIndex = i * data.num_channels + channel;
const sample = dataView.getInt16(sampleIndex * 2, true) / 32768.0;
channelData[i] = sample;
}
}
// Schedule audio buffer playback
const source = audioContextRef.current!.createBufferSource();
source.buffer = audioBuffer;
source.connect(gainNodeRef.current!);
// Schedule seamless playback
const currentTime = audioContextRef.current!.currentTime;
if (nextStartTimeRef.current < currentTime) {
nextStartTimeRef.current = currentTime;
}
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += audioBuffer.duration;
// Track scheduled sources
audioQueueRef.current.push(source);
source.onended = () => {
const index = audioQueueRef.current.indexOf(source);
if (index > -1) {
audioQueueRef.current.splice(index, 1);
}
};
setBufferedDuration(nextStartTimeRef.current - currentTime);
}
} catch (error) {
logger.error('Error processing audio data:', error);
}
};
ws.onerror = (error) => {
logger.error('WebSocket error:', error);
setConnectionStatus('error');
};
ws.onclose = (event) => {
setConnectionStatus('disconnected');
logger.info('Audio stream disconnected', { code: event.code, reason: event.reason });
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
} catch (error) {
logger.error('Error connecting to audio stream:', error);
setConnectionStatus('error');
} finally {
isConnectingRef.current = false;
}
}, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel]); // Removed connectionStatus to avoid loops
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
// Stop all scheduled audio
audioQueueRef.current.forEach(source => {
try {
source.stop();
} catch {
// Ignore if already stopped
}
});
audioQueueRef.current = [];
nextStartTimeRef.current = 0;
setBufferedDuration(0);
setAudioLevel(0);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
}, []);
// Handle play/pause
useEffect(() => {
if (isPlaying && sessionStatus === 'running') {
connectWebSocket();
} else {
disconnect();
}
return () => {
disconnect();
};
}, [isPlaying, sessionStatus, connectWebSocket, disconnect]); // Include stable callbacks
// Handle audio role changes
useEffect(() => {
// Use ref to check connection state to avoid dependency issues
if (isPlaying && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
logger.info('Audio role changed, reconnecting with new role:', audioRole);
// Reconnect with new role
disconnect();
// Set a flag to prevent double connections
const timer = setTimeout(() => {
if (isPlaying) {
connectWebSocket();
}
}, 500);
return () => clearTimeout(timer);
}
}, [audioRole, isPlaying, connectWebSocket, disconnect]); // Include all dependencies
// Update volume
useEffect(() => {
if (gainNodeRef.current) {
gainNodeRef.current.gain.value = volume;
}
}, [volume]);
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected': return 'bg-green-500';
case 'connecting': return 'bg-yellow-500';
case 'error': return 'bg-red-500';
default: return 'bg-gray-500';
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Volume2 className="h-5 w-5" />
Live Audio Stream
</CardTitle>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<Badge variant={connectionStatus === 'connected' ? 'default' : 'secondary'}>
{connectionStatus}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Play/Pause Controls */}
<div className="flex items-center gap-4">
<Button
onClick={() => setIsPlaying(!isPlaying)}
disabled={sessionStatus !== 'running'}
size="sm"
variant={isPlaying ? 'default' : 'outline'}
>
{isPlaying ? (
<>
<Pause className="h-4 w-4 mr-2" />
Pause
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Play
</>
)}
</Button>
{/* Audio Role Selector */}
<div className="flex gap-1">
{(['mixed', 'actor', 'adversary'] as const).map((role) => (
<Button
key={role}
size="sm"
variant={audioRole === role ? 'default' : 'outline'}
onClick={() => setAudioRole(role)}
className="capitalize"
>
{role}
</Button>
))}
</div>
</div>
{/* Volume Control */}
<div className="flex items-center gap-4">
<VolumeX className="h-4 w-4 text-gray-500" />
<input
type="range"
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
min="0"
max="1"
step="0.01"
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<Volume2 className="h-4 w-4 text-gray-500" />
</div>
{/* Audio Level Meter */}
<div className="space-y-2">
<div className="text-sm text-gray-500">Audio Level</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-100"
style={{ width: `${audioLevel * 100}%` }}
/>
</div>
</div>
{/* Status Info */}
<div className="text-sm text-gray-500">
{connectionStatus === 'connected' && (
<>Streaming... (buffered: {bufferedDuration.toFixed(1)}s)</>
)}
{connectionStatus === 'connecting' && 'Connecting to audio stream...'}
{connectionStatus === 'error' && 'Failed to connect to audio stream'}
{connectionStatus === 'disconnected' && sessionStatus === 'running' && 'Click play to start streaming'}
{sessionStatus === 'pending' && 'Waiting for session to start...'}
{sessionStatus === 'completed' && 'Session completed'}
{sessionStatus === 'failed' && 'Session failed'}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,104 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { listTestSessionsApiV1LooptalkTestSessionsGet } from '@/client/sdk.gen';
import type { TestSessionResponse } from '@/client/types.gen';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { TestSessionCard } from './TestSessionCard';
import { TestSession } from './types';
interface LoopTalkTestSessionsListProps {
status?: 'active' | 'completed' | 'failed';
}
export function LoopTalkTestSessionsList({ status }: LoopTalkTestSessionsListProps) {
const [sessions, setSessions] = useState<TestSession[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user, getAccessToken } = useAuth();
useEffect(() => {
const fetchSessions = async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await listTestSessionsApiV1LooptalkTestSessionsGet({
query: status ? { status } : undefined,
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Transform API response to match UI types
const transformedSessions = (response.data || []).map((session: TestSessionResponse) => ({
id: session.id,
name: session.name,
description: '', // API doesn't return description
test_type: session.test_index !== null ? 'load_test' : 'single',
status: session.status,
actor_workflow_name: `Workflow ${session.actor_workflow_id}`,
adversary_workflow_name: `Workflow ${session.adversary_workflow_id}`,
created_at: session.created_at,
updated_at: session.created_at, // API doesn't have updated_at
test_metadata: session.config
}));
setSessions(transformedSessions);
} catch (err) {
logger.error('Error fetching test sessions:', err);
setError('Failed to load test sessions');
} finally {
setLoading(false);
}
};
fetchSessions();
}, [status, user, getAccessToken]);
if (loading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40 animate-pulse"></div>
))}
</div>
);
}
if (error) {
return (
<div className="text-red-500">
{error}
</div>
);
}
if (sessions.length === 0) {
return (
<div className="text-center py-12 px-4">
<div className="text-gray-500 mb-2">
No {status ? `${status} ` : ''}test sessions found
</div>
{!status && (
<p className="text-sm text-gray-400">
Create a new test session to start testing agent conversations
</p>
)}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sessions.map((session) => (
<TestSessionCard
key={session.id}
session={session}
/>
))}
</div>
);
}

View file

@ -1,188 +0,0 @@
'use client';
import { Volume2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
interface SimpleAudioPlayerProps {
testSessionId: number;
}
export function SimpleAudioPlayer({ testSessionId }: SimpleAudioPlayerProps) {
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
const [audioRole, setAudioRole] = useState<'mixed' | 'actor' | 'adversary'>('mixed');
const wsRef = useRef<WebSocket | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const [bufferedDuration, setBufferedDuration] = useState(0);
const { user, getAccessToken } = useAuth();
const audioQueueRef = useRef<AudioBufferSourceNode[]>([]);
const nextStartTimeRef = useRef(0);
useEffect(() => {
const connectWebSocket = async () => {
try {
if (!user) return;
// Get auth token
const accessToken = await getAccessToken();
// Create WebSocket connection - pass token as query param since WebSocket doesn't support headers
const httpBase = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
const baseUrl = httpBase.replace(/^http/, 'ws');
const wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// Create AudioContext
audioContextRef.current = new AudioContext();
ws.onopen = () => {
setConnectionStatus('connected');
};
ws.onmessage = async (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'audio' && data.audio) {
// Decode base64 audio data
const audioBytes = Uint8Array.from(atob(data.audio), c => c.charCodeAt(0));
// Create audio buffer from PCM data
const samplesPerChannel = audioBytes.length / (data.num_channels * 2); // 16-bit samples
const audioBuffer = audioContextRef.current!.createBuffer(
data.num_channels,
samplesPerChannel,
data.sample_rate
);
// Convert PCM to float samples for each channel
const dataView = new DataView(audioBytes.buffer);
for (let channel = 0; channel < data.num_channels; channel++) {
const channelData = audioBuffer.getChannelData(channel);
for (let i = 0; i < samplesPerChannel; i++) {
// Interleaved PCM data: L,R,L,R,... for stereo
const sampleIndex = i * data.num_channels + channel;
const sample = dataView.getInt16(sampleIndex * 2, true) / 32768.0;
channelData[i] = sample;
}
}
// Schedule audio buffer playback
const source = audioContextRef.current!.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current!.destination);
// Schedule seamless playback
const currentTime = audioContextRef.current!.currentTime;
if (nextStartTimeRef.current < currentTime) {
nextStartTimeRef.current = currentTime;
}
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += audioBuffer.duration;
// Keep track of scheduled sources for cleanup
audioQueueRef.current.push(source);
source.onended = () => {
const index = audioQueueRef.current.indexOf(source);
if (index > -1) {
audioQueueRef.current.splice(index, 1);
}
};
setBufferedDuration(prev => prev + audioBuffer.duration);
} else if (data.type === 'keepalive') {
// Connection is alive
}
} catch (error) {
logger.error('Error processing audio data:', error);
}
};
ws.onerror = (error) => {
logger.error('WebSocket error:', error);
setConnectionStatus('error');
};
ws.onclose = () => {
setConnectionStatus('error');
};
} catch (error) {
logger.error('Error connecting to audio stream:', error);
setConnectionStatus('error');
}
};
connectWebSocket();
// Cleanup
return () => {
if (wsRef.current) {
wsRef.current.close();
}
// Stop all scheduled audio
audioQueueRef.current.forEach(source => {
try {
source.stop();
} catch {
// Ignore if already stopped
}
});
audioQueueRef.current = [];
nextStartTimeRef.current = 0;
setBufferedDuration(0);
if (audioContextRef.current) {
audioContextRef.current.close();
}
};
}, [testSessionId, audioRole, user, getAccessToken]);
return (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Volume2 className="h-5 w-5 text-gray-600" />
<span className="font-medium">Live Audio Stream</span>
</div>
<Badge variant={connectionStatus === 'connected' ? 'default' : connectionStatus === 'error' ? 'destructive' : 'secondary'}>
{connectionStatus}
</Badge>
</div>
<div className="flex gap-2 mb-4">
<button
className={`px-3 py-1 rounded text-sm ${audioRole === 'mixed' ? 'bg-primary text-white' : 'bg-gray-200'}`}
onClick={() => setAudioRole('mixed')}
>
Mixed
</button>
<button
className={`px-3 py-1 rounded text-sm ${audioRole === 'actor' ? 'bg-primary text-white' : 'bg-gray-200'}`}
onClick={() => setAudioRole('actor')}
>
Actor Only
</button>
<button
className={`px-3 py-1 rounded text-sm ${audioRole === 'adversary' ? 'bg-primary text-white' : 'bg-gray-200'}`}
onClick={() => setAudioRole('adversary')}
>
Adversary Only
</button>
</div>
<div className="text-sm text-gray-500">
{connectionStatus === 'connected' && (
<>Audio streaming... (buffered: {bufferedDuration.toFixed(1)}s)</>
)}
{connectionStatus === 'connecting' && 'Connecting to audio stream...'}
{connectionStatus === 'error' && 'Failed to connect to audio stream'}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,103 +0,0 @@
'use client';
import { format } from 'date-fns';
import { Eye, Pause, Play, Users } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { TestSession } from './types';
interface TestSessionCardProps {
session: TestSession;
}
export function TestSessionCard({ session }: TestSessionCardProps) {
const router = useRouter();
const handleViewDetails = () => {
router.push(`/looptalk/${session.id}`);
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'active':
return 'default';
case 'completed':
return 'secondary';
case 'failed':
return 'destructive';
default:
return 'outline';
}
};
const getTestTypeIcon = (type: string) => {
switch (type) {
case 'load_test':
return <Users className="h-4 w-4" />;
default:
return <Play className="h-4 w-4" />;
}
};
return (
<Card className="hover:shadow-lg transition-shadow cursor-pointer" onClick={handleViewDetails}>
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-lg">{session.name}</CardTitle>
<Badge variant={getStatusBadgeVariant(session.status)}>
{session.status}
</Badge>
</div>
{session.description && (
<CardDescription>{session.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-600">
{getTestTypeIcon(session.test_type)}
<span className="capitalize">{session.test_type.replace('_', ' ')}</span>
</div>
<div className="text-sm text-gray-500">
Created: {format(new Date(session.created_at), 'MMM d, yyyy h:mm a')}
</div>
{session.test_metadata?.concurrent_pairs && (
<div className="text-sm text-gray-600">
Concurrent pairs: {session.test_metadata.concurrent_pairs}
</div>
)}
</div>
<div className="mt-4 flex gap-2">
{session.status === 'active' && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
// TODO: Implement pause functionality
}}
>
<Pause className="h-4 w-4 mr-1" />
Pause
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
handleViewDetails();
}}
>
<Eye className="h-4 w-4 mr-1" />
View
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,120 +0,0 @@
'use client';
import { Play, RotateCcw, Square } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import {
startTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPost,
stopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPost
} from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
interface TestSessionControlsProps {
session: {
id: number;
status: string;
test_type: string;
};
}
export function TestSessionControls({ session }: TestSessionControlsProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const { user, getAccessToken } = useAuth();
const handleStart = async () => {
if (!user) return;
setLoading(true);
try {
const accessToken = await getAccessToken();
await startTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPost({
path: {
test_session_id: session.id
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
toast.success('Test session started');
router.refresh();
} catch (error) {
logger.error('Error starting test session:', error);
toast.error('Failed to start test session');
} finally {
setLoading(false);
}
};
const handleStop = async () => {
if (!user) return;
setLoading(true);
try {
const accessToken = await getAccessToken();
await stopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPost({
path: {
test_session_id: session.id
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
toast.success('Test session stopped');
router.refresh();
} catch (error) {
logger.error('Error stopping test session:', error);
toast.error('Failed to stop test session');
} finally {
setLoading(false);
}
};
return (
<Card className="mt-4">
<CardContent className="pt-6">
<div className="flex gap-2">
{session.status === 'pending' && (
<Button
onClick={handleStart}
disabled={loading}
className="flex items-center gap-2"
>
<Play className="h-4 w-4" />
Start Test
</Button>
)}
{session.status === 'active' && (
<>
<Button
variant="destructive"
onClick={handleStop}
disabled={loading}
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
Stop Test
</Button>
</>
)}
{session.status === 'completed' && (
<Button
variant="outline"
onClick={handleStart}
disabled={loading}
className="flex items-center gap-2"
>
<RotateCcw className="h-4 w-4" />
Restart Test
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,71 +0,0 @@
'use client';
import { format } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { TestSession } from './types';
interface TestSessionDetailsProps {
session: TestSession;
}
export function TestSessionDetails({ session }: TestSessionDetailsProps) {
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'active':
return 'default';
case 'completed':
return 'secondary';
case 'failed':
return 'destructive';
default:
return 'outline';
}
};
return (
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-2xl">{session.name}</CardTitle>
{session.description && (
<CardDescription className="mt-2">{session.description}</CardDescription>
)}
</div>
<Badge variant={getStatusBadgeVariant(session.status)} className="text-lg px-3 py-1">
{session.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold text-sm text-gray-600 mb-1">Test Type</h3>
<p className="capitalize">{session.test_type.replace('_', ' ')}</p>
</div>
<div>
<h3 className="font-semibold text-sm text-gray-600 mb-1">Created</h3>
<p>{format(new Date(session.created_at), 'MMM d, yyyy h:mm a')}</p>
</div>
<div>
<h3 className="font-semibold text-sm text-gray-600 mb-1">Actor Workflow</h3>
<p>{session.actor_workflow_name}</p>
</div>
<div>
<h3 className="font-semibold text-sm text-gray-600 mb-1">Adversary Workflow</h3>
<p>{session.adversary_workflow_name}</p>
</div>
{session.test_metadata?.concurrent_pairs && (
<div>
<h3 className="font-semibold text-sm text-gray-600 mb-1">Concurrent Pairs</h3>
<p>{session.test_metadata.concurrent_pairs}</p>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,24 +0,0 @@
export interface TestSession {
id: number;
name: string;
description?: string;
test_type: string;
status: string;
actor_workflow_name: string;
adversary_workflow_name: string;
created_at: string;
updated_at: string;
test_metadata?: {
concurrent_pairs?: number;
[key: string]: unknown;
};
}
export interface Conversation {
id: number;
test_session_id: number;
conversation_pair_id?: string;
status: string;
created_at: string;
updated_at: string;
}

View file

@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, ExternalLin
import { useState } from "react";
import { WorkflowRunResponseSchema } from "@/client/types.gen";
import { CallTypeCell } from "@/components/CallTypeCell";
import { FilterBuilder } from "@/components/filters/FilterBuilder";
import { MediaPreviewButton, MediaPreviewDialog } from "@/components/MediaPreviewDialog";
import { Badge } from "@/components/ui/badge";
@ -189,9 +190,7 @@ export function WorkflowRunsTable({
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell>
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
<CallTypeCell mode={run.mode} callType={run.call_type} />
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'

View file

@ -29,10 +29,7 @@ const BLANK_WORKFLOW_DEFINITION = {
allow_interrupt: false,
invalid: false,
validationMessage: null,
is_static: false,
add_global_prompt: false,
wait_for_user_response: false,
detect_voicemail: true,
delayed_start: false,
is_start: true,
selected_through_edge: false,

View file

@ -1,12 +1,32 @@
'use client';
import { Archive, Pencil, RotateCcw } from 'lucide-react';
import {
Archive,
Check,
Folder as FolderIcon,
FolderInput,
Inbox,
Pencil,
RotateCcw,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { toast } from 'sonner';
import { updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut } from '@/client/sdk.gen';
import {
moveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPut,
updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut,
} from '@/client/sdk.gen';
import type { FolderResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
@ -21,17 +41,31 @@ interface Workflow {
status: string;
created_at: string;
total_runs?: number | null;
folder_id?: number | null;
}
interface WorkflowTableProps {
workflows: Workflow[];
showArchived: boolean;
/**
* When provided, each row gets a "Move to folder" action listing these
* folders. Omit it (e.g. for the archived list) to hide the control.
*/
folders?: FolderResponse[];
/** The folder this table is rendered under; null means "Uncategorized". */
currentFolderId?: number | null;
}
export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
export function WorkflowTable({
workflows,
showArchived,
folders,
currentFolderId = null,
}: WorkflowTableProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [loadingWorkflowId, setLoadingWorkflowId] = useState<number | null>(null);
const [movingWorkflowId, setMovingWorkflowId] = useState<number | null>(null);
const handleEdit = (id: number) => {
router.push(`/workflow/${id}`);
@ -67,6 +101,30 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
}
};
const handleMove = async (id: number, folderId: number | null) => {
setMovingWorkflowId(id);
try {
const response = await moveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPut({
path: { workflow_id: id },
body: { folder_id: folderId },
});
if (response.error) {
throw new Error('Failed to move agent');
}
toast.success(
folderId === null ? 'Moved to Uncategorized' : 'Agent moved',
);
startTransition(() => {
router.refresh();
});
} catch (error) {
console.error('Error moving workflow:', error);
toast.error('Failed to move agent');
} finally {
setMovingWorkflowId(null);
}
};
return (
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
@ -114,6 +172,52 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
<Pencil size={16} />
Edit
</Button>
{folders && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={movingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{movingWorkflowId === workflow.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<FolderInput size={16} />
)}
Move
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={currentFolderId === null}
onClick={() => handleMove(workflow.id, null)}
>
<Inbox size={14} className="mr-2" />
Uncategorized
{currentFolderId === null && (
<Check size={14} className="ml-auto" />
)}
</DropdownMenuItem>
{folders.map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onClick={() => handleMove(workflow.id, folder.id)}
>
<FolderIcon size={14} className="mr-2" />
<span className="truncate">{folder.name}</span>
{folder.id === currentFolderId && (
<Check size={14} className="ml-auto shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant={showArchived ? "default" : "outline"}
size="sm"

View file

@ -0,0 +1,71 @@
"use client";
import { MessageSquare, Mic, MicOff } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { ConversationStatus } from "./types";
interface ConversationContainerProps {
title: string;
status: ConversationStatus;
children: ReactNode;
messageCount?: number;
}
const STATUS_CONFIG = {
ready: {
icon: MicOff,
label: "Ready",
className: "bg-muted text-muted-foreground",
},
live: {
icon: Mic,
label: "Live",
className: "bg-green-500/10 text-green-600 dark:text-green-400",
},
ended: {
icon: MicOff,
label: "Ended",
className: "bg-muted text-muted-foreground",
},
} satisfies Record<ConversationStatus, { icon: typeof Mic; label: string; className: string }>;
export function ConversationContainer({
title,
status,
children,
messageCount,
}: ConversationContainerProps) {
const statusConfig = STATUS_CONFIG[status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate whitespace-nowrap text-sm font-medium">{title}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{messageCount !== undefined && messageCount > 0 ? (
<span className="text-xs text-muted-foreground">{messageCount} messages</span>
) : null}
<div
className={cn(
"flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs",
statusConfig.className,
)}
>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</div>
</div>
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,15 @@
"use client";
import { MessageSquare } from "lucide-react";
import type { ConversationEmptyStateData } from "./types";
export function ConversationEmptyState({ title, subtitle }: ConversationEmptyStateData) {
return (
<div className="flex h-full flex-col items-center justify-center text-sm text-muted-foreground">
<MessageSquare className="mb-4 h-10 w-10 opacity-30" />
<p className="font-medium">{title}</p>
<p className="mt-1 px-4 text-center text-xs">{subtitle}</p>
</div>
);
}

View file

@ -0,0 +1,80 @@
"use client";
import type { ReactNode } from "react";
import { MessageBubble } from "./MessageBubble";
import { NodeTransitionMarker } from "./NodeTransitionMarker";
import { NoticeCard } from "./NoticeCard";
import { ToolCallCard } from "./ToolCallCard";
import type { ConversationItem } from "./types";
interface ConversationItemViewProps {
item: ConversationItem;
actions?: ReactNode;
}
export function ConversationItemView({ item, actions }: ConversationItemViewProps) {
if (item.kind === "message") {
const isUser = item.role === "user";
const bubble = (
<MessageBubble
role={item.role}
text={item.text}
final={item.final}
tone={item.tone}
reasoningDurationMs={item.reasoningDurationMs}
containerClassName={isUser && actions ? "min-w-0 flex-1 justify-end" : undefined}
/>
);
if (isUser && actions) {
return (
<div className="group flex w-full justify-end">
<div className="flex w-full items-end justify-end gap-2">
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-border/60 bg-background/95 px-1 py-0.5 shadow-sm">
{actions}
</div>
{bubble}
</div>
</div>
);
}
return (
<div className="group space-y-1">
{bubble}
{actions ? (
<div className="flex h-5 items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{actions}
</div>
) : null}
</div>
);
}
if (item.kind === "tool-call") {
return (
<ToolCallCard
functionName={item.functionName}
status={item.status}
argumentsValue={item.arguments}
resultValue={item.result}
reasoningDurationMs={item.reasoningDurationMs}
/>
);
}
if (item.kind === "node-transition") {
return <NodeTransitionMarker nodeName={item.nodeName} />;
}
return (
<NoticeCard
tone={item.tone}
title={item.title}
text={item.text}
linkHref={item.linkHref}
linkLabel={item.linkLabel}
/>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface ConversationRailFrameProps {
children: ReactNode;
className?: string;
header?: ReactNode;
footer?: ReactNode;
}
export function ConversationRailFrame({
children,
className,
header,
footer,
}: ConversationRailFrameProps) {
return (
<div
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm",
className,
)}
>
{header ? <div className="shrink-0 border-b border-border px-4 py-3">{header}</div> : null}
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
{footer ? <div className="shrink-0 border-t border-border px-4 py-3">{footer}</div> : null}
</div>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { ConversationEmptyState } from "./ConversationEmptyState";
import { ConversationItemView } from "./ConversationItemView";
import type { ConversationEmptyStateData, ConversationItem } from "./types";
interface ConversationTimelineProps {
items: ConversationItem[];
autoScroll?: boolean;
scrollBehavior?: ScrollBehavior;
emptyState: ConversationEmptyStateData;
pendingIndicator?: ReactNode;
renderItemActions?: (item: ConversationItem) => ReactNode;
className?: string;
}
export function ConversationTimeline({
items,
autoScroll = false,
scrollBehavior = "auto",
emptyState,
pendingIndicator,
renderItemActions,
className,
}: ConversationTimelineProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollEndRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!autoScroll) {
return;
}
scrollEndRef.current?.scrollIntoView({ behavior: scrollBehavior, block: "end" });
}, [autoScroll, items, pendingIndicator, scrollBehavior]);
return (
<div ref={scrollContainerRef} className={cn("flex-1 overflow-y-auto", className)}>
{items.length === 0 && !pendingIndicator ? (
<ConversationEmptyState title={emptyState.title} subtitle={emptyState.subtitle} />
) : (
<div className="space-y-3 p-4">
{items.map((item) => (
<ConversationItemView
key={item.id}
item={item}
actions={renderItemActions?.(item)}
/>
))}
{pendingIndicator}
<div ref={scrollEndRef} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,63 @@
"use client";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
interface MessageBubbleProps {
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
reasoningDurationMs?: number;
containerClassName?: string;
}
export function MessageBubble({
role,
text,
final = true,
tone = "default",
reasoningDurationMs,
containerClassName,
}: MessageBubbleProps) {
const isUser = role === "user";
const isMuted = tone === "muted";
return (
<div className={cn("flex", isUser ? "justify-end" : "justify-start", containerClassName)}>
<div className="flex max-w-[85%] flex-col gap-1">
{!isUser && reasoningDurationMs !== undefined ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<div
className={cn(
"whitespace-pre-wrap break-words rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm",
isUser
? "rounded-br-md bg-primary text-primary-foreground"
: isMuted
? "rounded-bl-md border border-dashed border-border bg-background text-muted-foreground"
: "rounded-bl-md border border-slate-200/80 bg-muted text-foreground",
!final && "opacity-70",
)}
>
<div>{text}</div>
{!final ? (
<div
className={cn(
"mt-1 text-[10px] italic",
isUser ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
speaking...
</div>
) : null}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { GitBranch } from "lucide-react";
interface NodeTransitionMarkerProps {
nodeName: string;
}
export function NodeTransitionMarker({ nodeName }: NodeTransitionMarkerProps) {
return (
<div className="flex items-center gap-2 py-2">
<div className="h-px flex-1 bg-border" />
<div className="inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 text-xs">
<GitBranch className="h-3 w-3 text-blue-500" />
<span className="font-medium text-blue-700 dark:text-blue-400">{nodeName}</span>
</div>
<div className="h-px flex-1 bg-border" />
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { AlertTriangle, ExternalLink, MicOff } from "lucide-react";
import { cn } from "@/lib/utils";
interface NoticeCardProps {
tone: "warning" | "error";
title: string;
text: string;
linkHref?: string;
linkLabel?: string;
}
export function NoticeCard({
tone,
title,
text,
linkHref,
linkLabel,
}: NoticeCardProps) {
const isWarning = tone === "warning";
const Icon = isWarning ? MicOff : AlertTriangle;
return (
<div
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-2",
isWarning
? "border-amber-500/20 bg-amber-500/10"
: "border-red-500/20 bg-red-500/10",
)}
>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
isWarning ? "text-amber-500" : "text-red-500",
)}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-xs font-medium",
isWarning ? "text-amber-700 dark:text-amber-400" : "text-red-700 dark:text-red-400",
)}
>
{title}
</div>
<div
className={cn(
"mt-0.5 break-words text-sm",
isWarning ? "text-amber-600 dark:text-amber-300" : "text-red-600 dark:text-red-300",
)}
>
{text}
</div>
{linkHref && linkLabel ? (
<a
href={linkHref}
target="_blank"
rel="noopener noreferrer"
className={cn(
"mt-1 inline-flex items-center gap-1 text-xs hover:underline",
isWarning ? "text-amber-600 dark:text-amber-400" : "text-red-600 dark:text-red-400",
)}
>
{linkLabel} <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import {
conversationItemsFromLiveFeedback,
conversationItemsFromRealtimeFeedbackEvents,
} from "./adapters/fromRealtimeFeedback";
import { ConversationContainer } from "./ConversationContainer";
import { ConversationTimeline } from "./ConversationTimeline";
import type {
ConversationStatus,
RealtimeFeedbackMessage,
WorkflowRunLogs,
} from "./types";
import { countConversationMessages } from "./utils";
interface LiveModeProps {
mode: "live";
messages: RealtimeFeedbackMessage[];
isCallActive: boolean;
isCallCompleted: boolean;
}
interface HistoricalModeProps {
mode: "historical";
logs: WorkflowRunLogs | null;
}
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
export function RealtimeFeedback(props: RealtimeFeedbackProps) {
let items;
let status: ConversationStatus;
let title: string;
let emptyState: { title: string; subtitle: string };
let autoScroll = false;
if (props.mode === "historical") {
items = props.logs?.realtime_feedback_events
? conversationItemsFromRealtimeFeedbackEvents(props.logs.realtime_feedback_events)
: [];
status = "ended";
title = "Call Transcript";
emptyState = {
title: "No conversation recorded",
subtitle: "Real-time feedback events were not captured for this call",
};
} else {
items = conversationItemsFromLiveFeedback(props.messages);
status = props.isCallActive ? "live" : props.isCallCompleted ? "ended" : "ready";
title = "Live Transcript";
emptyState = {
title: "No messages yet",
subtitle: props.isCallActive
? "Start speaking to see the transcript"
: "Start the call to begin the conversation",
};
autoScroll = true;
}
return (
<ConversationContainer
title={title}
status={status}
messageCount={countConversationMessages(items) || undefined}
>
<ConversationTimeline
items={items}
autoScroll={autoScroll}
emptyState={emptyState}
/>
</ConversationContainer>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { Brain, ChevronRight, Wrench } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { formatConversationValue } from "./utils";
interface ToolCallCardProps {
functionName: string;
status: "running" | "completed";
argumentsValue?: unknown;
resultValue?: unknown;
reasoningDurationMs?: number;
}
export function ToolCallCard({
functionName,
status,
argumentsValue,
resultValue,
reasoningDurationMs,
}: ToolCallCardProps) {
const [open, setOpen] = useState(false);
const hasArguments = argumentsValue !== undefined;
const hasResult = resultValue !== undefined;
const hasDetails = hasArguments || hasResult;
return (
<div className="flex justify-center">
<div className="flex w-full max-w-[85%] flex-col gap-1">
{reasoningDurationMs !== undefined ? (
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<Collapsible
open={hasDetails ? open : false}
onOpenChange={hasDetails ? setOpen : undefined}
className="rounded-2xl border border-amber-500/20 bg-amber-500/10"
>
<div className="flex items-start gap-2 px-3.5 py-3 text-sm">
<Wrench className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-amber-700 dark:text-amber-400">
{functionName}()
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] uppercase tracking-[0.14em]",
status === "running"
? "border-amber-400/60 text-amber-700 dark:text-amber-300"
: "border-emerald-500/30 text-emerald-700 dark:text-emerald-300",
)}
>
{status === "running" ? "Running" : "Completed"}
</Badge>
</div>
{hasDetails ? (
<div className="mt-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-90",
)}
/>
Details
</button>
</CollapsibleTrigger>
</div>
) : null}
</div>
</div>
{hasDetails ? (
<CollapsibleContent className="border-t border-amber-500/20 px-3.5 py-3">
<div className="space-y-3">
{hasArguments ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Arguments
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(argumentsValue)}
</pre>
</div>
) : null}
{hasResult ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Result
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(resultValue)}
</pre>
</div>
) : null}
</div>
</CollapsibleContent>
) : null}
</Collapsible>
</div>
</div>
);
}

View file

@ -0,0 +1,283 @@
import type {
ConversationItem,
RealtimeFeedbackEvent,
RealtimeFeedbackMessage,
} from "../types";
function feedbackEventText(event: RealtimeFeedbackEvent) {
return (
event.payload.text ??
event.payload.error ??
(typeof event.payload.result === "string" ? event.payload.result : undefined) ??
event.payload.function_name ??
event.payload.node_name ??
""
);
}
function liveFeedbackItem(message: RealtimeFeedbackMessage, reasoningDurationMs?: number): ConversationItem | null {
if (message.type === "ttfb-metric") {
return null;
}
if (message.type === "user-transcription") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "user",
text: message.text,
final: message.final,
};
}
if (message.type === "bot-text") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "assistant",
text: message.text,
final: message.final,
reasoningDurationMs,
};
}
if (message.type === "function-call") {
return {
kind: "tool-call",
id: message.id,
timestamp: message.timestamp,
functionName: message.functionName ?? "tool",
toolCallId: message.toolCallId,
arguments: message.arguments,
result: message.result,
status: message.status ?? "completed",
reasoningDurationMs,
};
}
if (message.type === "node-transition") {
return {
kind: "node-transition",
id: message.id,
timestamp: message.timestamp,
nodeId: message.nodeId,
nodeName: message.nodeName ?? message.text,
previousNodeId: message.previousNodeId,
previousNodeName: message.previousNode,
allowInterrupt: message.allowInterrupt,
};
}
if (message.type === "interrupt-warning") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: message.text,
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
};
}
if (message.type === "pipeline-error") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "error",
title: message.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: message.text,
fatal: message.fatal,
};
}
return null;
}
export function conversationItemsFromLiveFeedback(messages: RealtimeFeedbackMessage[]) {
const items: ConversationItem[] = [];
let pendingReasoningDurationMs: number | undefined;
messages.forEach((message) => {
if (message.type === "ttfb-metric") {
if (message.ttfbSeconds !== undefined) {
pendingReasoningDurationMs = message.ttfbSeconds * 1000;
}
return;
}
const item = liveFeedbackItem(message, pendingReasoningDurationMs);
if (!item) {
return;
}
items.push(item);
if (item.kind === "message" || item.kind === "tool-call") {
pendingReasoningDurationMs = undefined;
}
});
return items;
}
export function conversationItemsFromRealtimeFeedbackEvents(events: RealtimeFeedbackEvent[]) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
let pendingReasoningDurationMs: number | undefined;
let currentBotItemIndex: number | null = null;
let currentBotTurn: number | null = null;
events.forEach((event, index) => {
if (event.type === "rtf-ttfb-metric") {
if (event.payload.ttfb_seconds !== undefined) {
pendingReasoningDurationMs = event.payload.ttfb_seconds * 1000;
}
return;
}
if (event.type === "rtf-user-transcription") {
currentBotItemIndex = null;
currentBotTurn = null;
items.push({
kind: "message",
id: `user-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "user",
text: feedbackEventText(event),
final: event.payload.final,
});
return;
}
if (event.type === "rtf-bot-text") {
const text = feedbackEventText(event);
const lastItem = currentBotItemIndex !== null ? items[currentBotItemIndex] : null;
if (
currentBotItemIndex !== null &&
currentBotTurn === event.turn &&
lastItem?.kind === "message" &&
lastItem.role === "assistant"
) {
items[currentBotItemIndex] = {
...lastItem,
text: `${lastItem.text} ${text}`.trim(),
};
return;
}
items.push({
kind: "message",
id: `bot-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "assistant",
text,
final: event.payload.final,
reasoningDurationMs: pendingReasoningDurationMs,
});
currentBotItemIndex = items.length - 1;
currentBotTurn = event.turn;
pendingReasoningDurationMs = undefined;
return;
}
currentBotItemIndex = null;
currentBotTurn = null;
if (event.type === "rtf-function-call-start") {
const toolCallId = event.payload.tool_call_id;
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
arguments: event.payload.arguments,
status: "running",
reasoningDurationMs: pendingReasoningDurationMs,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-function-call-end") {
const toolCallId = event.payload.tool_call_id;
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: event.payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-result-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
result: event.payload.result,
status: "completed",
reasoningDurationMs: pendingReasoningDurationMs,
});
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-node-transition") {
items.push({
kind: "node-transition",
id: `node-${event.turn}-${index}`,
timestamp: event.timestamp,
nodeId: event.payload.node_id,
nodeName: event.payload.node_name ?? feedbackEventText(event) ?? "Node",
previousNodeId: event.payload.previous_node_id,
previousNodeName: event.payload.previous_node_name ?? event.payload.previous_node,
allowInterrupt: event.payload.allow_interrupt,
});
return;
}
if (event.type === "rtf-interrupt-warning") {
items.push({
kind: "notice",
id: `warning-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: feedbackEventText(event),
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
});
return;
}
if (event.type === "rtf-pipeline-error") {
items.push({
kind: "notice",
id: `error-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "error",
title: event.payload.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: feedbackEventText(event),
fatal: event.payload.fatal,
});
}
});
return items;
}

View file

@ -0,0 +1,180 @@
import type { ConversationItem } from "../types";
interface TextChatMessageLike {
text?: string;
created_at?: string;
}
interface TextChatEventLike {
type?: unknown;
payload?: unknown;
created_at?: unknown;
}
interface TextChatTurnLike {
id: string;
status?: string;
created_at?: string;
user_message?: TextChatMessageLike | null;
assistant_message?: TextChatMessageLike | null;
events?: Array<Record<string, unknown>>;
}
function asRecord(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
function conversationItemsFromTextChatEvents(
events: Array<Record<string, unknown>>,
turnId: string,
fallbackTimestamp?: string,
) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
events.forEach((rawEvent, index) => {
const event = rawEvent as TextChatEventLike;
const eventType = asString(event.type);
const payload = asRecord(event.payload);
if (!eventType || !payload) {
return;
}
const timestamp = asString(event.created_at) ?? fallbackTimestamp;
if (eventType === "node_transition") {
const nodeName = asString(payload.node_name) ?? "Node";
items.push({
kind: "node-transition",
id: `${turnId}-node-${index}`,
turnId,
timestamp,
nodeId: asString(payload.node_id),
nodeName,
previousNodeId: asString(payload.previous_node_id),
previousNodeName: asString(payload.previous_node_name),
allowInterrupt: typeof payload.allow_interrupt === "boolean" ? payload.allow_interrupt : undefined,
});
return;
}
if (eventType === "execution_error") {
items.push({
kind: "notice",
id: `${turnId}-error-${index}`,
turnId,
timestamp,
tone: "error",
title: "Execution Error",
text: asString(payload.message) ?? "Execution error",
fatal: true,
});
return;
}
if (eventType === "tool_call_started") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "running",
arguments: payload.arguments,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
return;
}
if (eventType === "tool_call_result") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-result-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "completed",
result: payload.result,
});
}
});
return items;
}
export function conversationItemsFromTextChatTurns(turns: TextChatTurnLike[]) {
const items: ConversationItem[] = [];
turns.forEach((turn) => {
if (turn.user_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-user`,
turnId: turn.id,
timestamp: turn.user_message.created_at ?? turn.created_at,
role: "user",
text: turn.user_message.text,
});
}
items.push(
...conversationItemsFromTextChatEvents(
turn.events ?? [],
turn.id,
turn.created_at,
),
);
if (turn.assistant_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-assistant`,
turnId: turn.id,
timestamp: turn.assistant_message.created_at ?? turn.created_at,
role: "assistant",
text: turn.assistant_message.text,
});
return;
}
if (turn.status === "failed") {
items.push({
kind: "message",
id: `${turn.id}-assistant-failed`,
turnId: turn.id,
timestamp: turn.created_at,
role: "assistant",
text: "Agent turn failed",
tone: "muted",
});
}
});
return items;
}

View file

@ -0,0 +1,5 @@
export * from "./ConversationContainer";
export * from "./ConversationRailFrame";
export * from "./ConversationTimeline";
export * from "./RealtimeFeedback";
export * from "./types";

View file

@ -0,0 +1,117 @@
export type ConversationStatus = "ready" | "live" | "ended";
export type RealtimeFeedbackMessageType =
| "user-transcription"
| "bot-text"
| "function-call"
| "node-transition"
| "ttfb-metric"
| "pipeline-error"
| "interrupt-warning";
export interface RealtimeFeedbackMessage {
id: string;
type: RealtimeFeedbackMessageType;
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
toolCallId?: string;
arguments?: unknown;
result?: unknown;
status?: "running" | "completed";
nodeId?: string;
nodeName?: string;
previousNodeId?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface RealtimeFeedbackEvent {
type: string;
payload: {
text?: string;
final?: boolean;
user_id?: string;
timestamp?: string;
function_name?: string;
tool_call_id?: string;
arguments?: unknown;
result?: unknown;
node_id?: string;
node_name?: string;
previous_node_id?: string;
previous_node?: string;
previous_node_name?: string;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
timestamp: string;
turn: number;
}
export interface WorkflowRunLogs {
realtime_feedback_events?: RealtimeFeedbackEvent[];
}
interface ConversationItemBase {
id: string;
timestamp?: string;
turnId?: string;
reasoningDurationMs?: number;
}
export interface ConversationMessageItem extends ConversationItemBase {
kind: "message";
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
}
export interface ConversationToolCallItem extends ConversationItemBase {
kind: "tool-call";
functionName: string;
toolCallId?: string;
status: "running" | "completed";
arguments?: unknown;
result?: unknown;
}
export interface ConversationNodeTransitionItem extends ConversationItemBase {
kind: "node-transition";
nodeId?: string;
nodeName: string;
previousNodeId?: string;
previousNodeName?: string;
allowInterrupt?: boolean;
}
export interface ConversationNoticeItem extends ConversationItemBase {
kind: "notice";
tone: "warning" | "error";
title: string;
text: string;
fatal?: boolean;
linkHref?: string;
linkLabel?: string;
}
export type ConversationItem =
| ConversationMessageItem
| ConversationToolCallItem
| ConversationNodeTransitionItem
| ConversationNoticeItem;
export interface ConversationEmptyStateData {
title: string;
subtitle: string;
}

View file

@ -0,0 +1,21 @@
import type { ConversationItem } from "./types";
export function formatConversationValue(value: unknown) {
if (value == null) {
return "None";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function countConversationMessages(items: ConversationItem[]) {
return items.filter(
(item) => item.kind === "message" && item.tone !== "muted",
).length;
}

View file

@ -0,0 +1,63 @@
'use client';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { WorkflowTable } from '../WorkflowTable';
import { FolderSection } from './FolderSection';
interface AgentFolderViewProps {
/** Active (non-archived) agents only. */
workflows: WorkflowListResponse[];
folders: FolderResponse[];
}
/**
* Renders active agents grouped into collapsible folder sections.
*
* When the organization has no folders yet, this falls back to the original
* flat table so the feature stays invisible until someone creates a folder.
*/
export function AgentFolderView({ workflows, folders }: AgentFolderViewProps) {
// No folders → keep the original flat list (no folder chrome, nowhere to move to).
if (folders.length === 0) {
return <WorkflowTable workflows={workflows} showArchived={false} />;
}
// Group agents by folder. Agents whose folder_id is null — or points at a
// folder we didn't get back — fall into "Uncategorized".
const folderIds = new Set(folders.map((f) => f.id));
const byFolder = new Map<number, WorkflowListResponse[]>();
const uncategorized: WorkflowListResponse[] = [];
for (const wf of workflows) {
if (wf.folder_id != null && folderIds.has(wf.folder_id)) {
const bucket = byFolder.get(wf.folder_id) ?? [];
bucket.push(wf);
byFolder.set(wf.folder_id, bucket);
} else {
uncategorized.push(wf);
}
}
return (
<div className="space-y-1">
{folders.map((folder) => (
<FolderSection
key={folder.id}
kind="folder"
folder={folder}
workflows={byFolder.get(folder.id) ?? []}
allFolders={folders}
defaultOpen={false}
/>
))}
{uncategorized.length > 0 && (
<FolderSection
kind="uncategorized"
workflows={uncategorized}
allFolders={folders}
/>
)}
</div>
);
}

View file

@ -0,0 +1,46 @@
'use client';
import { FolderPlus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { createFolderApiV1FolderPost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { FolderFormDialog } from './FolderFormDialog';
export function CreateFolderButton() {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const handleCreate = async (name: string) => {
const response = await createFolderApiV1FolderPost({ body: { name } });
if (response.error) {
// 409 = duplicate name; surface the server's message when present.
const detail =
(response.error as { detail?: string })?.detail ??
'Failed to create folder';
toast.error(detail);
throw new Error(detail);
}
toast.success(`Folder "${name}" created`);
router.refresh();
};
return (
<>
<Button variant="outline" onClick={() => setIsOpen(true)}>
<FolderPlus className="w-4 h-4 mr-2" />
New Folder
</Button>
<FolderFormDialog
open={isOpen}
onOpenChange={setIsOpen}
title="Create folder"
submitLabel="Create"
onSubmit={handleCreate}
/>
</>
);
}

View file

@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface FolderFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
/** Pre-fill the input (used when renaming). */
initialName?: string;
submitLabel: string;
/** Resolve to close the dialog; reject/throw to keep it open (e.g. on error). */
onSubmit: (name: string) => Promise<void>;
}
/**
* Shared single-field dialog used for both creating and renaming a folder.
* Keeps name validation and the pending state in one place.
*/
export function FolderFormDialog({
open,
onOpenChange,
title,
initialName = '',
submitLabel,
onSubmit,
}: FolderFormDialogProps) {
const [name, setName] = useState(initialName);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset the field whenever the dialog (re)opens.
useEffect(() => {
if (open) setName(initialName);
}, [open, initialName]);
const trimmed = name.trim();
const canSubmit = trimmed.length > 0 && trimmed !== initialName.trim() && !isSubmitting;
const handleSubmit = async () => {
if (!canSubmit) return;
setIsSubmitting(true);
try {
await onSubmit(trimmed);
onOpenChange(false);
} catch {
// onSubmit surfaces its own error toast; keep the dialog open.
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="folder-name">Folder name</Label>
<Input
id="folder-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sales, Support, Onboarding"
maxLength={100}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isSubmitting ? 'Saving...' : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,255 @@
'use client';
import {
Archive,
ChevronRight,
Folder as FolderIcon,
FolderOpen,
Inbox,
MoreVertical,
Pencil,
Trash2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import {
deleteFolderApiV1FolderFolderIdDelete,
renameFolderApiV1FolderFolderIdPut,
} from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import logger from '@/lib/logger';
import { cn } from '@/lib/utils';
import { WorkflowTable } from '../WorkflowTable';
import { FolderFormDialog } from './FolderFormDialog';
/**
* - `folder` a real, renameable/deletable folder of active agents
* - `uncategorized` active agents with no folder
* - `archived` archived agents (restore-only; not a move target)
*/
type SectionKind = 'folder' | 'uncategorized' | 'archived';
interface FolderSectionProps {
kind: SectionKind;
/** Required when kind === 'folder'; ignored otherwise. */
folder?: FolderResponse | null;
workflows: WorkflowListResponse[];
/** All folders, passed through so each row's "Move to folder" menu has targets. */
allFolders?: FolderResponse[];
/** Defaults to open only for Uncategorized; folders and Archived start collapsed. */
defaultOpen?: boolean;
}
export function FolderSection({
kind,
folder = null,
workflows,
allFolders = [],
defaultOpen,
}: FolderSectionProps) {
const router = useRouter();
const [open, setOpen] = useState(defaultOpen ?? kind === 'uncategorized');
const [isRenaming, setIsRenaming] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isFolder = kind === 'folder';
const isArchived = kind === 'archived';
const count = workflows.length;
const title = isFolder ? (folder?.name ?? '') : isArchived ? 'Archived' : 'Uncategorized';
const handleRename = async (name: string) => {
if (!folder) return;
const response = await renameFolderApiV1FolderFolderIdPut({
path: { folder_id: folder.id },
body: { name },
});
if (response.error) {
const detail =
(response.error as { detail?: string })?.detail ??
'Failed to rename folder';
toast.error(detail);
throw new Error(detail);
}
toast.success('Folder renamed');
router.refresh();
};
const handleDelete = async () => {
if (!folder) return;
setIsDeleting(true);
try {
const response = await deleteFolderApiV1FolderFolderIdDelete({
path: { folder_id: folder.id },
});
if (response.error) {
throw new Error('Failed to delete folder');
}
toast.success(`Folder "${folder.name}" deleted`);
setConfirmDelete(false);
router.refresh();
} catch (err) {
logger.error(`Error deleting folder: ${err}`);
toast.error('Failed to delete folder');
} finally {
setIsDeleting(false);
}
};
return (
<div className="mb-3">
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-1">
<CollapsibleTrigger asChild>
<button
className="group flex flex-1 items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors hover:bg-accent"
aria-label={`Toggle ${title}`}
>
<ChevronRight
size={16}
className={cn(
'shrink-0 text-muted-foreground transition-transform duration-200',
open && 'rotate-90',
)}
/>
{isFolder ? (
open ? (
<FolderOpen size={17} className="shrink-0 text-amber-500" />
) : (
<FolderIcon size={17} className="shrink-0 text-amber-500" />
)
) : isArchived ? (
<Archive size={16} className="shrink-0 text-muted-foreground" />
) : (
<Inbox size={17} className="shrink-0 text-muted-foreground" />
)}
<span
className={cn('font-medium', !isFolder && 'text-muted-foreground')}
>
{title}
</span>
<Badge variant="secondary" className="ml-1 font-normal">
{count}
</Badge>
</button>
</CollapsibleTrigger>
{isFolder && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
aria-label="Folder actions"
>
<MoreVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsRenaming(true)}>
<Pencil size={14} className="mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setConfirmDelete(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
<div className="pl-7 pt-2">
{count > 0 ? (
<WorkflowTable
workflows={workflows}
showArchived={isArchived}
// Archived agents are restore-only — not a move target.
folders={isArchived ? undefined : allFolders}
currentFolderId={folder?.id ?? null}
/>
) : (
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center text-sm text-muted-foreground">
{isArchived
? 'No archived agents.'
: isFolder
? 'This folder is empty. Use “Move to folder” on an agent to add it here.'
: 'No uncategorized agents.'}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{isFolder && folder && (
<>
<FolderFormDialog
open={isRenaming}
onOpenChange={setIsRenaming}
title="Rename folder"
initialName={folder.name}
submitLabel="Rename"
onSubmit={handleRename}
/>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {folder.name}?</AlertDialogTitle>
<AlertDialogDescription>
The {count} agent{count === 1 ? '' : 's'} in this folder
wont be deleted theyll move to Uncategorized.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete folder'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
);
}

View file

@ -9,6 +9,7 @@ export const WORKFLOW_RUN_MODES = {
CLOUDONIX: 'cloudonix',
WEBRTC: 'webrtc',
SMALL_WEBRTC: 'smallwebrtc',
TEXTCHAT: 'textchat',
ARI: 'ari',
TELNYX: 'telnyx',
PLIVO: 'plivo'

View file

@ -2,15 +2,19 @@
import { createContext, useContext, useEffect, useState } from 'react';
export type TooltipKey = 'web_call' | 'customize_workflow'; // Add more tooltip keys as needed
export type TooltipKey = 'web_call' | 'customize_workflow';
export type OnboardingActionKey = 'web_call_started';
interface OnboardingState {
seenTooltips: TooltipKey[];
completedActions: OnboardingActionKey[];
}
interface OnboardingContextType {
hasSeenTooltip: (key: TooltipKey) => boolean;
markTooltipSeen: (key: TooltipKey) => void;
hasCompletedAction: (key: OnboardingActionKey) => boolean;
markActionCompleted: (key: OnboardingActionKey) => void;
resetOnboarding: () => void;
}
@ -18,6 +22,7 @@ const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
const defaultState: OnboardingState = {
seenTooltips: [],
completedActions: [],
};
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
@ -59,6 +64,19 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
}));
};
const hasCompletedAction = (key: OnboardingActionKey): boolean => {
return onboardingState.completedActions.includes(key);
};
const markActionCompleted = (key: OnboardingActionKey) => {
setOnboardingState(prev => ({
...prev,
completedActions: prev.completedActions.includes(key)
? prev.completedActions
: [...prev.completedActions, key]
}));
};
const resetOnboarding = () => {
setOnboardingState(defaultState);
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
@ -69,6 +87,8 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
value={{
hasSeenTooltip,
markTooltipSeen,
hasCompletedAction,
markActionCompleted,
resetOnboarding
}}
>

View file

@ -2,13 +2,9 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { client } from '@/client/client.gen';
import { getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet } from '@/client/sdk.gen';
import { useAuth } from '@/lib/auth';
interface TelephonyConfigWarningsResponse {
telnyx_missing_webhook_public_key_count: number;
}
interface TelephonyConfigWarningsContextType {
telnyxMissingWebhookPublicKeyCount: number;
refresh: () => Promise<void>;
@ -34,11 +30,8 @@ export function TelephonyConfigWarningsProvider({ children }: { children: ReactN
const doFetch = useCallback(async () => {
setLoading(true);
try {
const res = await client.get<TelephonyConfigWarningsResponse>({
url: '/api/v1/organizations/telephony-config-warnings',
});
const data = res.data as TelephonyConfigWarningsResponse | undefined;
setCount(data?.telnyx_missing_webhook_public_key_count ?? 0);
const res = await getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet();
setCount(res.data?.telnyx_missing_webhook_public_key_count ?? 0);
} catch {
setCount(0);
} finally {

View file

@ -14,7 +14,7 @@ interface Result {
const CACHE_KEY = "dograh-latest-release";
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const SEMVER_RE = /^(?:[a-z][a-z0-9-]*-)?v?(\d+)\.(\d+)\.(\d+)$/i;
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
function parseSemver(tag: string): [number, number, number] | null {
const m = tag.match(SEMVER_RE);
@ -56,11 +56,11 @@ export function useLatestReleaseVersion(
}
let cancelled = false;
fetch("https://api.github.com/repos/dograh-hq/dograh/releases/latest")
fetch("/api/config/latest-version")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data?.tag_name) return;
const tag = data.tag_name as string;
if (cancelled || !data?.latest) return;
const tag = data.latest as string;
try {
localStorage.setItem(
CACHE_KEY,
@ -72,7 +72,7 @@ export function useLatestReleaseVersion(
setLatest(tag);
})
.catch(() => {
// silent — don't break the sidebar if GitHub is unreachable
// silent — don't break the sidebar if the lookup fails
});
return () => {
@ -80,19 +80,13 @@ export function useLatestReleaseVersion(
};
}, [enabled, currentVersion]);
const normalizedCurrent = currentVersion
? currentVersion.startsWith("v")
? currentVersion
: `v${currentVersion}`
: null;
const currentParsed = normalizedCurrent ? parseSemver(normalizedCurrent) : null;
const currentParsed = currentVersion ? parseSemver(currentVersion) : null;
const latestParsed = latest ? parseSemver(latest) : null;
const isBehind = !!(
normalizedCurrent &&
currentVersion &&
latest &&
isOlder(normalizedCurrent, latest)
isOlder(currentVersion, latest)
);
const isLatest = !!(

View file

@ -108,11 +108,17 @@ function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
);
}
const translationOverrides = {
"Email": "Business Email",
"Sign in with {provider}": "Sign in with {provider} Business",
"Sign up with {provider}": "Sign up with {provider} Business",
};
export function StackProviderWrapper({ children }: StackProviderWrapperProps) {
const stackClientApp = getStackClientApp();
return (
<StackProvider app={stackClientApp}>
<StackProvider app={stackClientApp} translationOverrides={translationOverrides}>
<StackTheme>
<StackAuthContextProvider>
{children}

View file

@ -131,9 +131,7 @@ export async function getServerAccessToken(): Promise<string | null> {
}
} else if (authProvider === 'local') {
// Get token from cookies (created by middleware)
const oss_token = await getOSSToken();
logger.debug(`oss_token: ${oss_token}`);
return oss_token;
return await getOSSToken();
}
return null;