feat: agent versioning and model configurations override (#227)

* feat: add tests and migrations

* feat: workflow versioning among published and draft

* feat: add a new settings page to simplify workflow detail page

* fix: fix tsclient generation
This commit is contained in:
Abhishek 2026-04-08 19:20:31 +05:30 committed by GitHub
parent f5fa9ce717
commit 38d1d928b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 10158 additions and 3131 deletions

View file

@ -88,7 +88,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
if (response.data) {
const integrations = Array.isArray(response.data) ? response.data : [response.data];
const googleSheet = integrations.find(i => i.provider === 'google-sheet');
const googleSheet = integrations.find((i: IntegrationResponse) => i.provider === 'google-sheet');
setGoogleIntegration(googleSheet || null);
}
} catch (error) {

View file

@ -106,7 +106,7 @@ export default function EditCampaignPage() {
setScheduleEnabled(c.schedule_config.enabled);
setScheduleTimezone(c.schedule_config.timezone);
if (c.schedule_config.slots.length > 0) {
setTimeSlots(c.schedule_config.slots.map(s => ({ ...s })));
setTimeSlots(c.schedule_config.slots.map((s: TimeSlot) => ({ ...s })));
}
}

View file

@ -9,6 +9,7 @@ import {
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
} from '@/client/sdk.gen';
import type { WorkflowRunDetail } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { Card } from '@/components/ui/card';
@ -140,7 +141,7 @@ export default function ReportsPage() {
if (response.data && response.data.length > 0) {
// Prepare CSV content
const headers = ['Phone Number', 'Disposition', 'Duration (seconds)', 'Workflow Run URL'];
const rows = response.data.map(run => {
const rows = response.data.map((run: WorkflowRunDetail) => {
const url = `${window.location.origin}/workflow/${run.workflow_id}/run/${run.run_id}`;
return [
run.phone_number || '',
@ -153,7 +154,7 @@ export default function ReportsPage() {
// Create CSV content
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
...rows.map((row: string[]) => row.map((cell: string) => `"${cell}"`).join(','))
].join('\n');
// Create blob and download

View file

@ -6,10 +6,11 @@ import {
Panel,
ReactFlow,
} from "@xyflow/react";
import { BookA, BrushCleaning, Maximize2, Mic, Minus, PhoneOff, Plus, Rocket, Settings, Variable } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { BrushCleaning, Maximize2, Minus, Plus, Settings } from 'lucide-react';
import { useRouter } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from '@/client/types.gen';
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
@ -20,13 +21,8 @@ import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, QANode, StartCall, TriggerNode, WebhookNode } from "../../../components/flow/nodes";
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
import { DictionaryDialog } from './components/DictionaryDialog';
import { EmbedDialog } from './components/EmbedDialog';
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { RecordingsDialog } from './components/RecordingsDialog';
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
import { VoicemailDetectionDialog } from './components/VoicemailDetectionDialog';
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
@ -61,22 +57,27 @@ interface RenderWorkflowProps {
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
initialVersionNumber?: number | null;
initialVersionStatus?: string | null;
user: { id: string; email?: string };
}
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user }: RenderWorkflowProps) {
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
const router = useRouter();
const { userConfig } = useUserConfig();
const ttsProvider = (userConfig?.tts?.provider as string) ?? "";
const ttsModel = (userConfig?.tts?.model as string) ?? "";
const ttsVoiceId = (userConfig?.tts?.voice as string) ?? "";
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
const [isDictionaryDialogOpen, setIsDictionaryDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
const [isVoicemailDialogOpen, setIsVoicemailDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [activeVersionId, setActiveVersionId] = useState<number | null>(null);
// 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);
const versionsFetched = useRef(false);
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
@ -89,9 +90,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
workflowName,
isDirty,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
setNodes,
setEdges,
setIsDirty,
setIsAddNodePanelOpen,
handleNodeSelect,
@ -100,10 +100,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onEdgesChange,
onNodesChange,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations,
dictionary,
saveDictionary
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -113,6 +109,123 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
user,
});
// Derive hasDraft from the current version status
const hasDraft = currentVersionStatus === "draft";
// Fetch workflow versions, optionally forcing a refresh
const fetchVersions = useCallback(async (force = false) => {
if (versionsFetched.current && !force) return;
setVersionsLoading(true);
try {
const response = await getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet({
path: { workflow_id: workflowId },
});
const data = response.data as WorkflowVersion[] | undefined;
if (data) {
setVersions(data);
// Set active version to draft if exists, else published
const current = data.find((v) => v.status === "draft") ?? data.find((v) => v.status === "published");
if (current) {
setActiveVersionId(current.id);
setCurrentVersionNumber(current.version_number);
setCurrentVersionStatus(current.status);
}
}
versionsFetched.current = true;
} finally {
setVersionsLoading(false);
}
}, [workflowId]);
const handleOpenVersionPanel = useCallback(() => {
setIsVersionPanelOpen(true);
fetchVersions();
}, [fetchVersions]);
const handleSelectVersion = useCallback((version: WorkflowVersion) => {
setActiveVersionId(version.id);
const wfJson = version.workflow_json;
const flowNodes = (wfJson.nodes ?? []) as FlowNode[];
const flowEdges = (wfJson.edges ?? []) as FlowEdge[];
// Update the Zustand store directly instead of rfInstance.current.setNodes().
// This keeps data flow unidirectional (store → props → ReactFlow) and avoids
// xyflow's d3 event handlers interfering with React's event delegation.
// The key={activeVersionId} on <ReactFlow> forces a clean remount.
setNodes(flowNodes);
setEdges(flowEdges);
// Never mark dirty when switching versions — historical versions are
// read-only, and loading the draft is restoring the saved state.
setIsDirty(false);
setIsVersionPanelOpen(false);
}, [setNodes, setEdges, setIsDirty]);
// Determine if we are viewing a historical (non-current) version.
// The "current" version is the draft if one exists, otherwise the published version.
// Anything else (archived, or published while a draft exists) is historical.
const isViewingHistoricalVersion = useMemo(() => {
if (!activeVersionId || versions.length === 0) return false;
const activeVersion = versions.find((v) => v.id === activeVersionId);
if (!activeVersion) return false;
if (activeVersion.status === "draft") return false;
if (activeVersion.status === "published" && !hasDraft) return false;
return true;
}, [activeVersionId, versions, hasDraft]);
// Return to the draft version, creating one from published if needed
const handleBackToDraft = useCallback(async () => {
const existingDraft = versions.find((v) => v.status === "draft");
if (existingDraft) {
handleSelectVersion(existingDraft);
return;
}
// No draft exists — ask the backend to create one from published
const response = await createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost({
path: { workflow_id: workflowId },
});
const draft = response.data;
if (draft) {
setCurrentVersionNumber(draft.version_number);
setCurrentVersionStatus(draft.status);
// Load draft nodes/edges via the Zustand store (same approach as handleSelectVersion)
const flowNodes = (draft.workflow_json?.nodes ?? []) as FlowNode[];
const flowEdges = (draft.workflow_json?.edges ?? []) as FlowEdge[];
setNodes(flowNodes);
setEdges(flowEdges);
setActiveVersionId(draft.id);
setIsDirty(false);
// Refresh the version list so the new draft appears
fetchVersions(true);
}
}, [versions, handleSelectVersion, workflowId, setNodes, setEdges, setIsDirty, fetchVersions]);
// After a successful publish, refresh the version list and update status
const handlePublished = useCallback(() => {
setCurrentVersionStatus("published");
fetchVersions(true);
}, [fetchVersions]);
// Compute version label for the header.
// Uses currentVersionNumber/Status which update immediately from save responses,
// falling back to the versions list for history navigation.
const activeVersionLabel = useMemo(() => {
// When viewing a version from the history panel, use the versions list
if (activeVersionId && versions.length > 0) {
const v = versions.find((ver) => ver.id === activeVersionId);
if (v) {
const statusSuffix = v.status === "draft" ? " (Draft)" : v.status === "published" ? " (Published)" : "";
return `v${v.version_number}${statusSuffix}`;
}
}
// Otherwise use the immediately-available version info from save responses
if (currentVersionNumber != null) {
const statusSuffix = currentVersionStatus === "draft" ? " (Draft)" : currentVersionStatus === "published" ? " (Published)" : "";
return `v${currentVersionNumber}${statusSuffix}`;
}
return undefined;
}, [activeVersionId, versions, currentVersionNumber, currentVersionStatus]);
// Fetch documents, tools, and recordings once for the entire workflow
useEffect(() => {
const fetchData = async () => {
@ -161,13 +274,37 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
type: "custom"
}), []);
// 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.
// Uses the save response to immediately update version label and hasDraft.
const guardedSaveWorkflow = useCallback(async (updateWorkflowDefinition?: boolean) => {
if (isViewingHistoricalVersion) return;
const result = await saveWorkflow(updateWorkflowDefinition);
if (result) {
// If the versions list has been fetched (user interacted with versioning
// or published), refresh it so that activeVersionId points to the correct
// version. This is critical when a save creates a new draft from a
// published version: without refreshing, activeVersionId would still
// point to the old published version, causing isViewingHistoricalVersion
// to incorrectly return true and lock the editor into read-only mode.
if (versionsFetched.current) {
await fetchVersions(true);
} else {
if (result.versionNumber != null) setCurrentVersionNumber(result.versionNumber);
if (result.versionStatus) setCurrentVersionStatus(result.versionStatus);
}
}
}, [saveWorkflow, isViewingHistoricalVersion, fetchVersions]);
// Memoize the context value to prevent unnecessary re-renders
const workflowContextValue = useMemo(() => ({
saveWorkflow,
saveWorkflow: guardedSaveWorkflow,
documents,
tools,
recordings,
}), [saveWorkflow, documents, tools, recordings]);
readOnly: isViewingHistoricalVersion,
}), [guardedSaveWorkflow, documents, tools, recordings, isViewingHistoricalVersion]);
return (
<WorkflowProvider value={workflowContextValue}>
@ -180,21 +317,29 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
saveWorkflow={saveWorkflow}
saveWorkflow={guardedSaveWorkflow}
user={user}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
onHistoryClick={handleOpenVersionPanel}
activeVersionLabel={activeVersionLabel}
isViewingHistoricalVersion={isViewingHistoricalVersion}
onBackToDraft={handleBackToDraft}
hasDraft={hasDraft}
onPublished={handlePublished}
/>
{/* Workflow Canvas */}
<div className="flex-1 relative">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={onConnect}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
@ -204,6 +349,11 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
@ -212,124 +362,46 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
color="#94a3b8"
/>
{/* Top-right controls - vertical layout */}
<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>
{/* 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={() => setIsConfigurationsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Configurations</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsContextVarsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Variable className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Template Context Variables</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsDictionaryDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<BookA className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Dictionary</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsRecordingsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Mic className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Recordings</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsVoicemailDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<PhoneOff className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Voicemail Detection</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsEmbedDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Rocket className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Deploy Workflow</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
<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 */}
@ -386,25 +458,27 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
</TooltipContent>
</Tooltip>
{/* Tidy/Arrange Nodes */}
<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>
{/* 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>
</div>
@ -415,33 +489,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onClose={() => setIsAddNodePanelOpen(false)}
/>
<ConfigurationsDialog
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
/>
<TemplateContextVariablesDialog
open={isContextVarsDialogOpen}
onOpenChange={setIsContextVarsDialogOpen}
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
<DictionaryDialog
open={isDictionaryDialogOpen}
onOpenChange={setIsDictionaryDialogOpen}
dictionary={dictionary}
onSave={saveDictionary}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}
workflowId={workflowId}
workflowName={workflowName}
<VersionHistoryPanel
isOpen={isVersionPanelOpen}
onClose={() => setIsVersionPanelOpen(false)}
versions={versions}
loading={versionsLoading}
activeVersionId={activeVersionId}
onSelectVersion={handleSelectVersion}
/>
<PhoneCallDialog
@ -450,22 +504,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
workflowId={workflowId}
user={user}
/>
<RecordingsDialog
open={isRecordingsDialogOpen}
onOpenChange={setIsRecordingsDialogOpen}
workflowId={workflowId}
onRecordingsChange={setRecordings}
/>
{workflowConfigurations && (
<VoicemailDetectionDialog
open={isVoicemailDialogOpen}
onOpenChange={setIsVoicemailDialogOpen}
workflowConfigurations={workflowConfigurations}
onSave={(configurations) => saveWorkflowConfigurations(configurations, workflowName)}
/>
)}
</div>
</WorkflowProvider>
);

View file

@ -0,0 +1,54 @@
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { WorkflowConfigurations } from "@/types/workflow-configurations";
interface ModelConfigurationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowConfigurations: WorkflowConfigurations | null;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}
export const ModelConfigurationDialog = ({
open,
onOpenChange,
workflowConfigurations,
workflowName,
onSave,
}: ModelConfigurationDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Model Configuration</DialogTitle>
<DialogDescription>
Override global model settings for this workflow. Toggle individual services to customize.
</DialogDescription>
</DialogHeader>
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations?.model_overrides}
submitLabel="Save"
onSave={async (config) => {
await onSave(
{
...workflowConfigurations,
model_overrides: config.model_overrides as WorkflowConfigurations["model_overrides"],
} as WorkflowConfigurations,
workflowName,
);
onOpenChange(false);
}}
/>
</DialogContent>
</Dialog>
);
};

View file

@ -9,7 +9,7 @@ import {
listRecordingsApiV1WorkflowRecordingsGet,
transcribeAudioApiV1WorkflowRecordingsTranscribePost,
} from "@/client";
import type { RecordingResponseSchema } from "@/client/types.gen";
import type { RecordingResponseSchema, RecordingUploadResponseSchema } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -294,7 +294,7 @@ export const RecordingsDialog = ({
// Step 2: Upload all files to storage in parallel
await Promise.all(
items.map(async (item, idx) => {
items.map(async (item: RecordingUploadResponseSchema, idx: number) => {
const file = ready[idx].file;
const uploadResponse = await fetch(item.upload_url, {
method: "PUT",
@ -312,7 +312,7 @@ export const RecordingsDialog = ({
// Step 3: Create all recording records
await createRecordingsApiV1WorkflowRecordingsPost({
body: {
recordings: items.map((item, idx) => ({
recordings: items.map((item: RecordingUploadResponseSchema, idx: number) => ({
recording_id: item.recording_id,
workflow_id: workflowId,
tts_provider: ttsProvider,

View file

@ -0,0 +1,133 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { FileText, LoaderCircle, X } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export interface WorkflowVersion {
id: number;
version_number: number;
status: string;
created_at: string;
published_at: string | null;
workflow_json: { nodes?: unknown[]; edges?: unknown[]; viewport?: unknown };
workflow_configurations: Record<string, unknown> | null;
template_context_variables: Record<string, string> | null;
}
interface VersionHistoryPanelProps {
isOpen: boolean;
onClose: () => void;
versions: WorkflowVersion[];
loading: boolean;
activeVersionId: number | null;
onSelectVersion: (version: WorkflowVersion) => void;
}
const statusLabel: Record<string, string> = {
draft: "Draft",
published: "Published",
archived: "Archived",
};
const statusColor: Record<string, string> = {
draft: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
published: "bg-green-500/20 text-green-400 border-green-500/30",
archived: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
export const VersionHistoryPanel = ({
isOpen,
onClose,
versions,
loading,
activeVersionId,
onSelectVersion,
}: VersionHistoryPanelProps) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
return (
<div
className={`fixed z-51 right-0 top-0 h-full w-80 bg-[#1a1a1a] border-l border-[#2a2a2a] shadow-lg transform transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="p-4 h-full overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-white">
Version History
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-gray-400 hover:text-white hover:bg-[#2a2a2a]"
>
<X className="w-5 h-5" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<LoaderCircle className="w-6 h-6 text-gray-400 animate-spin" />
</div>
) : versions.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No versions found.
</p>
) : (
<div className="space-y-2">
{versions.map((version) => {
const isActive = version.id === activeVersionId;
const date = version.published_at || version.created_at;
return (
<button
key={version.id}
onClick={() => onSelectVersion(version)}
className={`w-full text-left p-3 rounded-lg border transition-colors cursor-pointer ${
isActive
? "border-teal-500/50 bg-teal-500/10"
: "border-[#2a2a2a] bg-[#222] hover:bg-[#2a2a2a]"
}`}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-white">
v{version.version_number}
</span>
</div>
{version.status !== "archived" && (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${
statusColor[version.status] ?? ""
}`}
>
{statusLabel[version.status] ?? version.status}
</span>
)}
</div>
<p className="text-xs text-gray-500">
{formatDistanceToNow(new Date(date), {
addSuffix: true,
})}
</p>
</button>
);
})}
</div>
)}
</div>
</div>
);
};

View file

@ -1,12 +1,15 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react";
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, Eye, History, LoaderCircle, MoreVertical, Phone, Rocket } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost } from "@/client/sdk.gen";
import {
duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost,
publishWorkflowApiV1WorkflowWorkflowIdPublishPost,
} from "@/client/sdk.gen";
import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
@ -33,6 +36,12 @@ interface WorkflowEditorHeaderProps {
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
onPhoneCallClick: () => void;
onHistoryClick: () => void;
activeVersionLabel?: string;
isViewingHistoricalVersion: boolean;
onBackToDraft: () => void;
hasDraft: boolean;
onPublished: () => void;
}
export const WorkflowEditorHeader = ({
@ -43,11 +52,18 @@ export const WorkflowEditorHeader = ({
saveWorkflow,
onRun,
onPhoneCallClick,
onHistoryClick,
activeVersionLabel,
isViewingHistoricalVersion,
onBackToDraft,
hasDraft,
onPublished,
workflowId,
}: WorkflowEditorHeaderProps) => {
const router = useRouter();
const [savingWorkflow, setSavingWorkflow] = useState(false);
const [duplicating, setDuplicating] = useState(false);
const [publishing, setPublishing] = useState(false);
const hasValidationErrors = workflowValidationErrors.length > 0;
const isCallDisabled = isDirty || hasValidationErrors;
@ -58,6 +74,25 @@ export const WorkflowEditorHeader = ({
setSavingWorkflow(false);
};
const handlePublish = async () => {
if (publishing) return;
setPublishing(true);
const promise = publishWorkflowApiV1WorkflowWorkflowIdPublishPost({
path: { workflow_id: workflowId },
});
toast.promise(promise, {
loading: "Publishing...",
success: "Workflow published successfully",
error: "Failed to publish workflow",
});
try {
await promise;
onPublished();
} finally {
setPublishing(false);
}
};
const handleBack = () => {
router.push("/workflow");
};
@ -121,10 +156,41 @@ export const WorkflowEditorHeader = ({
</div>
</div>
{/* Right section: Unsaved indicator + Call button + Save button */}
{/* Right section: Version + Unsaved indicator + Call button + Save button */}
<div className="flex items-center gap-3">
{/* Unsaved changes indicator */}
{isDirty && (
{/* Read-only banner when viewing a historical version */}
{isViewingHistoricalVersion && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-blue-500/30 bg-blue-500/10">
<Eye className="w-4 h-4 text-blue-400" />
<span className="text-sm text-blue-400">
Viewing {activeVersionLabel} Read only
</span>
</div>
)}
{/* Back to Draft button when viewing history */}
{isViewingHistoricalVersion && (
<Button
onClick={onBackToDraft}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
Back to Draft
</Button>
)}
{/* Version history button */}
<button
onClick={onHistoryClick}
className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-[#3a3a3a] hover:bg-[#2a2a2a] transition-colors cursor-pointer"
>
<History className="w-4 h-4 text-gray-400" />
{activeVersionLabel && !isViewingHistoricalVersion && (
<span className="text-sm text-gray-300">{activeVersionLabel}</span>
)}
</button>
{/* Unsaved changes indicator (hidden when viewing history) */}
{isDirty && !isViewingHistoricalVersion && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/10">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-sm text-yellow-500">Unsaved changes</span>
@ -177,57 +243,83 @@ export const WorkflowEditorHeader = ({
</Popover>
)}
{/* Call button with dropdown */}
<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={() => 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>
{/* 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={() => 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 */}
<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>
{/* 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
onClick={handlePublish}
disabled={isDirty || publishing || hasValidationErrors}
variant="outline"
className="border-[#3a3a3a] bg-transparent hover:bg-[#2a2a2a] text-white px-4"
>
{publishing ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Publishing...
</>
) : (
<>
<Rocket className="w-4 h-4 mr-2" />
Publish
</>
)}
</Button>
)}
{/* More options dropdown */}
<DropdownMenu>

View file

@ -8,6 +8,7 @@ interface WorkflowContextType {
documents?: DocumentResponseSchema[];
tools?: ToolResponse[];
recordings?: RecordingResponseSchema[];
readOnly?: boolean;
}
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);

View file

@ -165,7 +165,7 @@ interface UseWorkflowStateProps {
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
user: { id: string; email?: string }; // Minimal user type needed
user: { id: string; email?: string } | null;
}
export const useWorkflowState = ({
@ -300,7 +300,7 @@ export const useWorkflowState = ({
// Validate workflow function
const validateWorkflow = useCallback(async () => {
if (!user) return;
if (!user?.id) return;
try {
const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
path: {
@ -342,7 +342,7 @@ export const useWorkflowState = ({
if (response.data.is_valid === false && response.data.errors) {
const errors = response.data.errors;
errors.forEach((error) => {
errors.forEach((error: WorkflowError) => {
if (error.kind === 'node' && error.id) {
markNodeAsInvalid(error.id, error.message);
} else if (error.kind === 'edge' && error.id) {
@ -355,14 +355,14 @@ export const useWorkflowState = ({
logger.info('Workflow is valid');
}
}
} catch (error) {
} catch (error: unknown) {
logger.error(`Unexpected validation error: ${error}`);
}
}, [workflowId, user, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]);
// Save workflow function
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
if (!user || !rfInstance.current) return;
// Save workflow function. Returns version info from the API response.
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true): Promise<{ versionNumber?: number; versionStatus?: string } | undefined> => {
if (!user?.id || !rfInstance.current) return;
// Read nodes/edges from the Zustand store (synchronously up-to-date)
// and viewport from the ReactFlow instance to build the flow object.
// This avoids a race condition where rfInstance.toObject() may return
@ -370,8 +370,9 @@ export const useWorkflowState = ({
const { nodes: currentNodes, edges: currentEdges } = useWorkflowStore.getState();
const viewport = rfInstance.current.getViewport();
const flow = { nodes: currentNodes, edges: currentEdges, viewport };
let result: { versionNumber?: number; versionStatus?: string } | undefined;
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
@ -381,12 +382,19 @@ export const useWorkflowState = ({
},
});
setIsDirty(false);
if (response.data) {
result = {
versionNumber: response.data.version_number ?? undefined,
versionStatus: response.data.version_status ?? undefined,
};
}
} catch (error) {
logger.error(`Error saving workflow: ${error}`);
}
// Validate after saving
await validateWorkflow();
return result;
}, [workflowId, workflowName, setIsDirty, user, validateWorkflow]);
// Set up keyboard shortcut for save (Cmd/Ctrl + S)
@ -394,7 +402,9 @@ export const useWorkflowState = ({
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
saveWorkflow();
if (useWorkflowStore.getState().isDirty) {
saveWorkflow();
}
}
};
@ -439,7 +449,7 @@ export const useWorkflowState = ({
);
const onRun = async (mode: string) => {
if (!user) return;
if (!user?.id) return;
const workflowRunName = `WR-${getRandomId()}`;
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
@ -455,7 +465,7 @@ export const useWorkflowState = ({
// Save template context variables
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
if (!user) return;
if (!user?.id) return;
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
@ -477,12 +487,12 @@ export const useWorkflowState = ({
// Save workflow configurations
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => {
if (!user) return;
if (!user?.id) return;
// Preserve the current dictionary when saving other configurations
const currentDictionary = useWorkflowStore.getState().dictionary;
const configurationsWithDictionary: WorkflowConfigurations = { ...configurations, dictionary: currentDictionary };
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
@ -492,6 +502,22 @@ export const useWorkflowState = ({
workflow_configurations: configurationsWithDictionary as Record<string, unknown>,
},
});
if (response.error) {
const detail = (response.error as { detail?: unknown }).detail;
let msg = 'Failed to save workflow configurations';
if (typeof detail === 'string') {
msg = detail;
} else if (Array.isArray(detail)) {
msg = detail
.map((e: { model?: string; message?: string; msg?: string }) =>
e.model && e.message ? `${e.model}: ${e.message}` : (e.msg || JSON.stringify(e))
)
.join('\n');
}
throw new Error(msg);
}
setWorkflowConfigurations(configurationsWithDictionary);
// Set name directly in the store to avoid setWorkflowName which marks isDirty: true
useWorkflowStore.setState({ workflowName: newWorkflowName });
@ -550,6 +576,7 @@ export const useWorkflowState = ({
workflowConfigurations,
dictionary,
setNodes,
setEdges,
setIsDirty,
setIsAddNodePanelOpen,
handleNodeSelect,

View file

@ -82,6 +82,8 @@ export default function WorkflowDetailPage() {
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
initialVersionNumber={workflow.version_number ?? null}
initialVersionStatus={workflow.version_status ?? null}
user={stableUser}
/>
) : null;

View file

@ -526,7 +526,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
});
if (turnResponse.data) {
turnCredentialsRef.current = turnResponse.data;
logger.info(`TURN credentials obtained, TTL: ${turnCredentialsRef.current.ttl}s`);
logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.ttl}s`);
} else if (turnResponse.response.status === 503) {
// TURN not configured on server - this is OK, we'll use STUN only
logger.info('TURN server not configured, using STUN only');

View file

@ -0,0 +1,926 @@
"use client";
import { ArrowLeft, BookA, Brain, ExternalLink, Mic, PhoneOff, Rocket, Settings, Trash2Icon, Variable } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import type { WorkflowResponse } from "@/client/types.gen";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import SpinLoader from "@/components/SpinLoader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
type AmbientNoiseConfiguration,
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
DEFAULT_WORKFLOW_CONFIGURATIONS,
type TurnStopStrategy,
type VoicemailDetectionConfiguration,
type WorkflowConfigurations,
} from "@/types/workflow-configurations";
import { EmbedDialog } from "../components/EmbedDialog";
import { RecordingsDialog } from "../components/RecordingsDialog";
import { useWorkflowState } from "../hooks/useWorkflowState";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_AMBIENT_NOISE_CONFIG: AmbientNoiseConfiguration = {
enabled: false,
volume: 0.3,
};
const DEFAULT_VOICEMAIL_SYSTEM_PROMPT = `You are a voicemail detection classifier for an OUTBOUND calling system. A bot has called a phone number and you need to determine if a human answered or if the call went to voicemail based on the provided text.
HUMAN ANSWERED - LIVE CONVERSATION (respond "CONVERSATION"):
- Personal greetings: "Hello?", "Hi", "Yeah?", "John speaking"
- Interactive responses: "Who is this?", "What do you want?", "Can I help you?"
- Conversational tone expecting back-and-forth dialogue
- Questions directed at the caller: "Hello? Anyone there?"
- Informal responses: "Yep", "What's up?", "Speaking"
- Natural, spontaneous speech patterns
- Immediate acknowledgment of the call
VOICEMAIL SYSTEM (respond "VOICEMAIL"):
- Automated voicemail greetings: "Hi, you've reached [name], please leave a message"
- Phone carrier messages: "The number you have dialed is not in service", "Please leave a message", "All circuits are busy"
- Professional voicemail: "This is [name], I'm not available right now"
- Instructions about leaving messages: "leave a message", "leave your name and number"
- References to callback or messaging: "call me back", "I'll get back to you"
- Carrier system messages: "mailbox is full", "has not been set up"
- Business hours messages: "our office is currently closed"
Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's voicemail/recording.`;
// Sidebar navigation items
const NAV_ITEMS = [
{ id: "general", label: "General", icon: Settings },
{ id: "models", label: "Model Overrides", icon: Brain },
{ id: "variables", label: "Template Variables", icon: Variable },
{ id: "dictionary", label: "Dictionary", icon: BookA },
{ id: "voicemail", label: "Voicemail Detection", icon: PhoneOff },
{ id: "recordings", label: "Recordings", icon: Mic },
{ id: "deployment", label: "Deployment", icon: Rocket },
];
// ---------------------------------------------------------------------------
// Section: General
// ---------------------------------------------------------------------------
function GeneralSection({
workflowConfigurations,
workflowName,
onSave,
}: {
workflowConfigurations: WorkflowConfigurations;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}) {
const [name, setName] = useState(workflowName);
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
workflowConfigurations.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG,
);
const [maxCallDuration, setMaxCallDuration] = useState(workflowConfigurations.max_call_duration || 600);
const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState(workflowConfigurations.max_user_idle_timeout || 10);
const [smartTurnStopSecs, setSmartTurnStopSecs] = useState(workflowConfigurations.smart_turn_stop_secs || 2);
const [turnStopStrategy, setTurnStopStrategy] = useState<TurnStopStrategy>(
workflowConfigurations.turn_stop_strategy || "transcription",
);
const [contextCompactionEnabled, setContextCompactionEnabled] = useState(
workflowConfigurations.context_compaction_enabled ?? false,
);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(
{
...workflowConfigurations,
ambient_noise_configuration: ambientNoiseConfig,
max_call_duration: maxCallDuration,
max_user_idle_timeout: maxUserIdleTimeout,
smart_turn_stop_secs: smartTurnStopSecs,
turn_stop_strategy: turnStopStrategy,
context_compaction_enabled: contextCompactionEnabled,
},
name,
);
} catch (error) {
console.error("Failed to save general settings:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="general">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Settings className="h-4 w-4" />
General
</CardTitle>
<CardDescription>Agent name, call behavior, and turn detection.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.general} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Agent Name */}
<div className="space-y-2">
<Label htmlFor="workflow_name" className="text-sm font-medium">Agent Name</Label>
<Input
id="workflow_name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter Agent name"
/>
</div>
<Separator />
{/* Ambient Noise */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Ambient Noise</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Add background office ambient noise to make the conversation sound more natural.
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="ambient-noise-enabled" className="text-sm">Use Ambient Noise</Label>
<Switch
id="ambient-noise-enabled"
checked={ambientNoiseConfig.enabled}
onCheckedChange={(checked) =>
setAmbientNoiseConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
</div>
{ambientNoiseConfig.enabled && (
<div className="space-y-2">
<Label htmlFor="ambient-volume" className="text-xs">Volume</Label>
<Input
id="ambient-volume"
type="number"
step="0.1"
min="0"
max="1"
value={ambientNoiseConfig.volume}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) setAmbientNoiseConfig((prev) => ({ ...prev, volume: value }));
}}
/>
</div>
)}
</div>
<Separator />
{/* Turn Detection */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Turn Detection</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Configure how the agent detects when the user has finished speaking.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="turn_stop_strategy" className="text-xs">Detection Strategy</Label>
<Select
value={turnStopStrategy}
onValueChange={(value: TurnStopStrategy) => setTurnStopStrategy(value)}
>
<SelectTrigger id="turn_stop_strategy">
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="transcription">Transcription-based</SelectItem>
<SelectItem value="turn_analyzer">Smart Turn Analyzer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{turnStopStrategy === "transcription"
? "Best for short responses (1-2 word statements). Ends turn when transcription indicates completion."
: "Best for longer responses with natural pauses. Uses ML model to detect end of turn."}
</p>
</div>
{turnStopStrategy === "turn_analyzer" && (
<div className="space-y-2">
<Label htmlFor="smart_turn_stop_secs" className="text-xs">
Incomplete Turn Timeout (seconds)
</Label>
<Input
id="smart_turn_stop_secs"
type="number"
step="0.5"
min="0.5"
max="10"
value={smartTurnStopSecs}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0.5) setSmartTurnStopSecs(value);
}}
/>
<p className="text-xs text-muted-foreground">
Max silence duration before ending an incomplete turn. Default: 2 seconds
</p>
</div>
)}
</div>
<Separator />
{/* Context Compaction */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Context Compaction</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Automatically summarize conversation context when transitioning between nodes.
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="context-compaction-enabled" className="text-sm">
Enable Context Compaction
</Label>
<Switch
id="context-compaction-enabled"
checked={contextCompactionEnabled}
onCheckedChange={setContextCompactionEnabled}
/>
</div>
</div>
<Separator />
{/* Call Management */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Call Management</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Configure call duration limits and idle timeout settings.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max_call_duration" className="text-xs">Max Call Duration (seconds)</Label>
<Input
id="max_call_duration"
type="number"
min="1"
value={maxCallDuration}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) setMaxCallDuration(value);
}}
/>
<p className="text-xs text-muted-foreground">Default: 600 (10 minutes)</p>
</div>
<div className="space-y-2">
<Label htmlFor="max_user_idle_timeout" className="text-xs">
Max User Idle Timeout (seconds)
</Label>
<Input
id="max_user_idle_timeout"
type="number"
min="1"
value={maxUserIdleTimeout}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) setMaxUserIdleTimeout(value);
}}
/>
<p className="text-xs text-muted-foreground">Default: 10 seconds</p>
</div>
</div>
</div>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save General Settings"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section: Template Variables
// ---------------------------------------------------------------------------
function TemplateVariablesSection({
templateContextVariables,
onSave,
}: {
templateContextVariables: Record<string, string>;
onSave: (variables: Record<string, string>) => Promise<void>;
}) {
const [contextVars, setContextVars] = useState<Record<string, string>>(templateContextVariables);
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const [isSaving, setIsSaving] = useState(false);
const handleAdd = () => {
if (newKey && newValue) {
setContextVars((prev) => ({ ...prev, [newKey]: newValue }));
}
setNewKey("");
setNewValue("");
};
const handleRemove = (key: string) => {
setContextVars((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
const handleSave = async () => {
setIsSaving(true);
try {
let varsToSave = contextVars;
if (newKey && newValue) {
varsToSave = { ...varsToSave, [newKey]: newValue };
}
await onSave(varsToSave);
} catch (error) {
console.error("Failed to save variables:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="variables">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Variable className="h-4 w-4" />
Template Variables
</CardTitle>
<CardDescription>
Variables available in workflow prompts via {`{{variable_name}}`} syntax for testing the workflow.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.templateVariables} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing Variables */}
{Object.entries(contextVars).length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Current Variables</Label>
{Object.entries(contextVars).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 rounded-md border p-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{key}</div>
<div className="text-xs text-muted-foreground truncate">{value}</div>
</div>
<Button size="sm" variant="ghost" onClick={() => handleRemove(key)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* Add New Variable */}
<div className="space-y-3">
<Label className="text-sm font-medium">Add New Variable</Label>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<Label htmlFor="var-key" className="text-xs">Key</Label>
<Input
id="var-key"
placeholder="Enter variable key"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
/>
</div>
<div className="flex-1 space-y-1">
<Label htmlFor="var-value" className="text-xs">Value</Label>
<Input
id="var-value"
placeholder="Enter variable value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
/>
</div>
</div>
<Button size="sm" onClick={handleAdd} disabled={!newKey || !newValue}>
Add Variable
</Button>
</div>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Variables"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section: Dictionary
// ---------------------------------------------------------------------------
function DictionarySection({
dictionary,
onSave,
}: {
dictionary: string;
onSave: (dictionary: string) => Promise<void>;
}) {
const [dictionaryValue, setDictionaryValue] = useState(dictionary);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(dictionaryValue);
} catch (error) {
console.error("Failed to save dictionary:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="dictionary">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookA className="h-4 w-4" />
Dictionary
</CardTitle>
<CardDescription>
Add words the agent should actively listen for &mdash; company jargon, names,
industry terms. May incur extra cost depending on provider.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
placeholder="Enter words separated by comma (e.g. billing department, tretinoin)"
value={dictionaryValue}
onChange={(e) => setDictionaryValue(e.target.value)}
rows={4}
className="resize-none"
/>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Dictionary"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section: Voicemail Detection
// ---------------------------------------------------------------------------
function VoicemailSection({
workflowConfigurations,
workflowName,
onSave,
}: {
workflowConfigurations: WorkflowConfigurations;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}) {
const getConfig = (): VoicemailDetectionConfiguration => ({
...DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
...workflowConfigurations.voicemail_detection,
});
const [enabled, setEnabled] = useState(getConfig().enabled);
const [useWorkflowLlm, setUseWorkflowLlm] = useState(getConfig().use_workflow_llm);
const [provider, setProvider] = useState(getConfig().provider || "openai");
const [model, setModel] = useState(getConfig().model || "gpt-4.1");
const [apiKey, setApiKey] = useState(getConfig().api_key || "");
const [systemPrompt, setSystemPrompt] = useState(getConfig().system_prompt || DEFAULT_VOICEMAIL_SYSTEM_PROMPT);
const [longSpeechTimeout, setLongSpeechTimeout] = useState(getConfig().long_speech_timeout);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
const voicemailConfig: VoicemailDetectionConfiguration = {
enabled,
use_workflow_llm: useWorkflowLlm,
provider: useWorkflowLlm ? undefined : provider,
model: useWorkflowLlm ? undefined : model,
api_key: useWorkflowLlm ? undefined : apiKey,
system_prompt:
systemPrompt && systemPrompt !== DEFAULT_VOICEMAIL_SYSTEM_PROMPT ? systemPrompt : undefined,
long_speech_timeout: longSpeechTimeout,
};
await onSave(
{ ...workflowConfigurations, voicemail_detection: voicemailConfig },
workflowName,
);
} catch (error) {
console.error("Failed to save voicemail settings:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="voicemail">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<PhoneOff className="h-4 w-4" />
Voicemail Detection
</CardTitle>
<CardDescription>
Automatically detect and end calls when a voicemail system is reached.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2 rounded-md border bg-muted/20 p-2">
<Switch id="voicemail-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="voicemail-enabled">Enable Voicemail Detection</Label>
</div>
{enabled && (
<>
{/* LLM Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2 rounded-md border bg-muted/20 p-2">
<Switch
id="voicemail-use-workflow-llm"
checked={useWorkflowLlm}
onCheckedChange={setUseWorkflowLlm}
/>
<Label htmlFor="voicemail-use-workflow-llm">Use Workflow LLM</Label>
<Label className="ml-2 text-xs text-muted-foreground">
Use the LLM configured in your account settings.
</Label>
</div>
{!useWorkflowLlm && (
<LLMConfigSelector
provider={provider}
onProviderChange={setProvider}
model={model}
onModelChange={setModel}
apiKey={apiKey}
onApiKeyChange={setApiKey}
/>
)}
</div>
{/* System Prompt */}
<div className="space-y-2">
<Label>System Prompt</Label>
<p className="text-xs text-muted-foreground">
The LLM must respond with either &quot;CONVERSATION&quot; or &quot;VOICEMAIL&quot;.
</p>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[200px] font-mono text-xs"
/>
</div>
{/* Timing */}
<div className="space-y-2 rounded-md border bg-muted/10 p-3">
<Label className="font-medium">Timing</Label>
<div className="space-y-2">
<Label className="text-sm">Speech Cutoff (seconds)</Label>
<p className="text-xs text-muted-foreground">
Trigger classification early if first turn speech exceeds this duration.
</p>
<Input
type="number"
step="0.5"
min="1"
max="30"
value={longSpeechTimeout}
onChange={(e) => setLongSpeechTimeout(parseFloat(e.target.value) || 8.0)}
/>
</div>
</div>
</>
)}
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Voicemail Settings"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Page wrapper — handles auth & data fetching, then mounts the content
// component only when everything is loaded. This avoids useWorkflowState
// running with empty initial values and overwriting the Zustand store.
// ---------------------------------------------------------------------------
export default function WorkflowSettingsPage() {
const params = useParams();
const { user, redirectToLogin, loading: authLoading } = useAuth();
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !user) {
redirectToLogin();
}
}, [authLoading, user, redirectToLogin]);
useEffect(() => {
const fetchWorkflow = async () => {
if (!user) return;
try {
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: { workflow_id: Number(params.workflowId) },
});
setWorkflow(response.data);
} catch (err) {
setError("Failed to fetch workflow");
logger.error(`Error fetching workflow settings: ${err}`);
} finally {
setLoading(false);
}
};
if (user) fetchWorkflow();
}, [params.workflowId, user]);
if (loading || authLoading) return <SpinLoader />;
if (error || !workflow) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-lg text-destructive">{error || "Workflow not found"}</div>
</div>
);
}
if (!user) return null;
return <WorkflowSettingsContent workflow={workflow} user={user} />;
}
// ---------------------------------------------------------------------------
// Content — only mounts once the workflow API response is available, so
// useWorkflowState always initialises with real data.
// ---------------------------------------------------------------------------
function WorkflowSettingsContent({
workflow,
user,
}: {
workflow: WorkflowResponse;
user: { id: string; email?: string };
}) {
const router = useRouter();
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [activeSection, setActiveSection] = useState("general");
const workflowId = workflow.id;
const initialFlow = useMemo(
() => ({
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 0 },
}),
[workflow],
);
const initialTemplateContextVariables = useMemo(
() => (workflow.template_context_variables as Record<string, string>) || {},
[workflow],
);
const initialWorkflowConfigurations = useMemo(
() => (workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS,
[workflow],
);
const {
workflowName,
workflowConfigurations,
templateContextVariables,
dictionary,
saveWorkflowConfigurations,
saveTemplateContextVariables,
saveDictionary,
} = useWorkflowState({
initialWorkflowName: workflow.name,
workflowId,
initialFlow,
initialTemplateContextVariables,
initialWorkflowConfigurations,
user,
});
// Intersection observer for active sidebar link
useEffect(() => {
const ids = NAV_ITEMS.map((n) => n.id);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
break;
}
}
},
{ rootMargin: "-20% 0px -60% 0px" },
);
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
// Sections are gated on configurations being present in the store.
// After mount, initializeWorkflow runs in a useEffect — the first render
// may still have stale store data, but the next tick corrects it.
const dataReady = !!workflowConfigurations;
return (
<div className="min-h-screen">
{/* Sticky header */}
<header className="sticky top-0 z-10 flex items-center gap-3 border-b bg-background/95 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<p className="text-xs text-muted-foreground">Workflow Settings</p>
<h1 className="text-sm font-semibold">{workflowName || workflow.name}</h1>
</div>
</header>
{/* Main + right nav */}
<div className="mx-auto flex max-w-5xl gap-8 px-6 py-8">
{/* Sections */}
<div className="min-w-0 flex-1 space-y-8">
{dataReady && (
<>
{/* General */}
<GeneralSection
workflowConfigurations={workflowConfigurations}
workflowName={workflowName || workflow.name}
onSave={saveWorkflowConfigurations}
/>
{/* Model Overrides */}
<Card id="models">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4" />
Model Overrides
</CardTitle>
<CardDescription>
Override global model settings for this workflow. Toggle individual services to
customize.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent>
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations.model_overrides}
submitLabel="Save Model Overrides"
onSave={async (config) => {
await saveWorkflowConfigurations(
{
...workflowConfigurations,
model_overrides:
config.model_overrides as WorkflowConfigurations["model_overrides"],
} as WorkflowConfigurations,
workflowName,
);
}}
/>
</CardContent>
</Card>
{/* Template Variables */}
<TemplateVariablesSection
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
{/* Dictionary */}
<DictionarySection dictionary={dictionary} onSave={saveDictionary} />
{/* Voicemail Detection */}
<VoicemailSection
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
/>
{/* Recordings (dialog trigger) */}
<Card id="recordings">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mic className="h-4 w-4" />
Recordings
</CardTitle>
<CardDescription>
Upload or record audio for hybrid prompts. Use{" "}
<code className="rounded bg-muted px-1 text-xs">@</code> in prompt fields to
insert them.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.recordings} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardFooter className="border-t pt-6">
<Button variant="outline" onClick={() => setIsRecordingsDialogOpen(true)}>
Manage Recordings
</Button>
</CardFooter>
</Card>
{/* Deployment (dialog trigger) */}
<Card id="deployment">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Rocket className="h-4 w-4" />
Deployment
</CardTitle>
<CardDescription>
Generate and manage the embed configuration to deploy this workflow on external
websites.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.deployment} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardFooter className="border-t pt-6">
<Button variant="outline" onClick={() => setIsEmbedDialogOpen(true)}>
Configure Embed
</Button>
</CardFooter>
</Card>
</>
)}
</div>
{/* ---- Right-side sticky nav ---- */}
<nav className="hidden w-44 shrink-0 lg:block">
<div className="sticky top-20 space-y-1">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
On this page
</p>
{NAV_ITEMS.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className={`block rounded-md px-2 py-1 text-sm transition-colors hover:text-foreground ${
activeSection === item.id
? "font-medium text-foreground"
: "text-muted-foreground"
}`}
>
{item.label}
</a>
))}
</div>
</nav>
</div>
{/* Dialogs for complex sections */}
<RecordingsDialog
open={isRecordingsDialogOpen}
onOpenChange={setIsRecordingsDialogOpen}
workflowId={workflowId}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}
workflowId={workflowId}
workflowName={workflowName || workflow.name}
/>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
import type { WorkflowListResponse } from '@/client/types.gen';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
@ -46,12 +47,12 @@ async function WorkflowList() {
// Separate active and archived workflows
const activeWorkflows = allWorkflowData
.filter(w => w.status === 'active')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
.filter((w: WorkflowListResponse) => w.status === 'active')
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const archivedWorkflows = allWorkflowData
.filter(w => w.status === 'archived')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
.filter((w: WorkflowListResponse) => w.status === 'archived')
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return (
<>