Deployment settings (#189)

* moved settings to a modal inside the build view under deployment

* project name is a editable field in the build view

* project name is propogated correctly

* remove project name from settings modal

* split settings into phone and api

* added chat widget option to the deploy settings

* bring back top level settings tab with few options

* removed cancel option from twilio settings
This commit is contained in:
arkml 2025-07-30 09:01:08 +05:30 committed by GitHub
parent 55b2204912
commit 9d54d971d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 274 additions and 129 deletions

View file

@ -1,7 +1,8 @@
'use client';
import { Metadata } from "next";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Tab, Tabs } from "@heroui/react";
import { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { ReactNode, useEffect, useState } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
import { CopyButton } from "../../../../components/common/copy-button";
@ -14,8 +15,7 @@ import { RelativeTime } from "@primer/react";
import { Label } from "../../../lib/components/label";
import { FormSection } from "../../../lib/components/form-section";
import { Panel } from "@/components/common/panel-common";
import { ProjectSection } from './components/project';
import { VoiceSection } from "./components/voice";
import { ProjectSection, SimpleProjectSection } from './components/project';
export const metadata: Metadata = {
title: "Project config",
@ -187,11 +187,11 @@ export function ApiKeysSection({
API keys are used to authenticate requests to the Rowboat API.
</p>
<Button
onPress={handleCreateKey}
onClick={handleCreateKey}
size="sm"
startContent={<Plus className="h-4 w-4" />}
variant="flat"
isDisabled={loading}
variant="primary"
disabled={loading}
>
Create API key
</Button>
@ -321,10 +321,10 @@ export function SecretSection({
/>
<Button
size="sm"
variant="flat"
variant="primary"
color="warning"
onPress={handleRotateSecret}
isDisabled={loading}
onClick={handleRotateSecret}
disabled={loading}
>
Rotate
</Button>
@ -477,9 +477,8 @@ export function DeleteProjectSection({
<Button
color="danger"
size="sm"
onPress={onOpen}
isDisabled={loading}
isLoading={loading}
onClick={onOpen}
disabled={loading}
>
Delete project
</Button>
@ -508,13 +507,13 @@ export function DeleteProjectSection({
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
color="danger"
onPress={handleDelete}
isDisabled={!isValid}
onClick={handleDelete}
disabled={!isValid}
>
Delete Project
</Button>
@ -566,39 +565,34 @@ export function ConfigApp({
useChatWidget: boolean;
chatWidgetHost: string;
}) {
const [selected, setSelected] = useState("general");
return (
<div className="h-full overflow-auto p-6">
<Tabs
selectedKey={selected}
onSelectionChange={(key) => setSelected(key.toString())}
fullWidth
>
<Tab
key="general"
title="Project settings"
>
<Panel title="Project settings">
<ProjectSection
projectId={projectId}
useChatWidget={useChatWidget}
chatWidgetHost={chatWidgetHost}
/>
</Panel>
</Tab>
<Panel title="Project settings">
<ProjectSection
projectId={projectId}
useChatWidget={useChatWidget}
chatWidgetHost={chatWidgetHost}
/>
</Panel>
</div>
);
}
<Tab
key="twilio"
title="Twilio"
>
<Panel title="Twilio settings">
<VoiceSection
projectId={projectId}
/>
</Panel>
</Tab>
</Tabs>
export function SimpleConfigApp({
projectId,
onProjectConfigUpdated,
}: {
projectId: string;
onProjectConfigUpdated?: () => void;
}) {
return (
<div className="h-full overflow-auto p-6">
<Panel title="Project Settings">
<SimpleProjectSection
projectId={projectId}
onProjectConfigUpdated={onProjectConfigUpdated}
/>
</Panel>
</div>
);
}

View file

@ -3,8 +3,7 @@
import { ReactNode, useEffect, useState, useCallback } from "react";
import { Spinner, Dropdown, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getProjectConfig, updateProjectName, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../../actions/project_actions";
import { getProjectConfig, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret, updateProjectName } from "../../../../actions/project_actions";
import { CopyButton } from "../../../../../components/common/copy-button";
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { WithStringId } from "../../../../lib/types/types";
@ -14,6 +13,7 @@ import { RelativeTime } from "@primer/react";
import { Label } from "../../../../lib/components/label";
import { sectionHeaderStyles, sectionDescriptionStyles } from './shared-styles';
import { clsx } from "clsx";
import { InputField } from "../../../../lib/components/input-field";
export function Section({
title,
@ -61,10 +61,15 @@ export function RightContent({
return <div>{children}</div>;
}
function ProjectNameSection({ projectId }: { projectId: string }) {
function ProjectNameSection({
projectId,
onProjectConfigUpdated
}: {
projectId: string;
onProjectConfigUpdated?: () => void;
}) {
const [loading, setLoading] = useState(false);
const [projectName, setProjectName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
@ -74,44 +79,32 @@ function ProjectNameSection({ projectId }: { projectId: string }) {
});
}, [projectId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setProjectName(value);
if (!value.trim()) {
setError("Project name cannot be empty");
return;
async function updateName(name: string) {
setLoading(true);
await updateProjectName(projectId, name);
setProjectName(name);
setLoading(false);
if (onProjectConfigUpdated) {
onProjectConfigUpdated();
}
setError(null);
updateProjectName(projectId, value);
};
}
return <Section
title="Project Name"
description="The name of your project."
>
{loading ? (
<Spinner size="sm" />
) : (
<div className="space-y-2">
<div className={clsx(
"border rounded-lg focus-within:ring-2",
error
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={projectName || ''}
onChange={handleChange}
placeholder="Enter project name..."
className="w-full text-sm bg-transparent border-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
)}
<div className="space-y-4">
{loading ? (
<Spinner size="sm" />
) : (
<InputField
type="text"
value={projectName || ''}
onChange={updateName}
className="w-full"
/>
)}
</div>
</Section>;
}
@ -361,7 +354,7 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
</Section>;
}
function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, chatWidgetHost: string }) {
export function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, chatWidgetHost: string }) {
const [loading, setLoading] = useState(false);
const [chatClientId, setChatClientId] = useState<string | null>(null);
@ -534,11 +527,24 @@ export function ProjectSection({
}) {
return (
<div className="p-6 space-y-6">
<ProjectNameSection projectId={projectId} />
<ProjectIdSection projectId={projectId} />
<SecretSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
</div>
);
}
export function SimpleProjectSection({
projectId,
onProjectConfigUpdated,
}: {
projectId: string;
onProjectConfigUpdated?: () => void;
}) {
return (
<div className="p-6 space-y-6">
<ProjectNameSection projectId={projectId} onProjectConfigUpdated={onProjectConfigUpdated} />
<SecretSection projectId={projectId} />
<DeleteProjectSection projectId={projectId} />
</div>
);

View file

@ -302,7 +302,7 @@ export function VoiceSection({ projectId }: { projectId: string }) {
>
{existingConfig ? 'Update Twilio Config' : 'Import from Twilio'}
</Button>
{existingConfig ? (
{existingConfig && (
<Button
variant="primary"
color="red"
@ -312,24 +312,6 @@ export function VoiceSection({ projectId }: { projectId: string }) {
>
Delete Configuration
</Button>
) : (
<Button
variant="tertiary"
size="sm"
onClick={() => {
setFormState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
setError(null);
setIsDirty(false);
}}
disabled={loading}
>
Cancel
</Button>
)}
</div>
</div>

View file

@ -1,10 +1,9 @@
import { Metadata } from "next";
import App from "./app";
import { USE_CHAT_WIDGET } from "@/app/lib/feature_flags";
import { SimpleConfigApp } from "./app";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export const metadata: Metadata = {
title: "Project config",
title: "Project Settings",
};
export default async function Page(
@ -16,9 +15,7 @@ export default async function Page(
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <App
return <SimpleConfigApp
projectId={params.projectId}
useChatWidget={USE_CHAT_WIDGET}
chatWidgetHost={process.env.CHAT_WIDGET_HOST || ''}
/>;
}

View file

@ -20,6 +20,7 @@ export function App({
useRagS3Uploads,
useRagScraping,
defaultModel,
chatWidgetHost,
}: {
projectId: string;
useRag: boolean;
@ -27,6 +28,7 @@ export function App({
useRagS3Uploads: boolean;
useRagScraping: boolean;
defaultModel: string;
chatWidgetHost: string;
}) {
const [mode, setMode] = useState<'draft' | 'live'>('draft');
const [project, setProject] = useState<WithStringId<z.infer<typeof Project>> | null>(null);
@ -84,6 +86,13 @@ export function App({
setDataSources(updatedDataSources);
}, [projectId]);
const handleProjectConfigUpdate = useCallback(async () => {
// Refresh project config when project name or other settings change
const updatedProjectConfig = await getProjectConfig(projectId);
setProject(updatedProjectConfig);
setProjectConfig(updatedProjectConfig);
}, [projectId]);
// Auto-update data sources when there are pending ones
useEffect(() => {
if (!dataSources) return;
@ -144,6 +153,8 @@ export function App({
onRevertToLive={handleRevertToLive}
onProjectToolsUpdated={handleProjectToolsUpdate}
onDataSourcesUpdated={handleDataSourcesUpdate}
onProjectConfigUpdated={handleProjectConfigUpdate}
chatWidgetHost={chatWidgetHost}
/>}
</>
}

View file

@ -42,6 +42,7 @@ export default async function Page(
useRagS3Uploads={USE_RAG_S3_UPLOADS}
useRagScraping={USE_RAG_SCRAPING}
defaultModel={DEFAULT_MODEL}
chatWidgetHost={process.env.CHAT_WIDGET_HOST || ''}
/>
);
}

View file

@ -13,7 +13,7 @@ import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner,
import { PromptConfig } from "../entities/prompt_config";
import { DataSourceConfig } from "../entities/datasource_config";
import { RelativeTime } from "@primer/react";
import { USE_PRODUCT_TOUR } from "@/app/lib/feature_flags";
import { USE_PRODUCT_TOUR, USE_CHAT_WIDGET } from "@/app/lib/feature_flags";
import {
ResizableHandle,
@ -23,14 +23,19 @@ import {
import { Copilot } from "../copilot/app";
import { publishWorkflow } from "@/app/actions/project_actions";
import { saveWorkflow } from "@/app/actions/project_actions";
import { updateProjectName } from "@/app/actions/project_actions";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon } from "lucide-react";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { ProductTour } from "@/components/common/product-tour";
import { ModelsResponse } from "@/app/lib/types/billing_types";
import { AgentGraphVisualizer } from "../entities/AgentGraphVisualizer";
import { Panel } from "@/components/common/panel-common";
import { Button as CustomButton } from "@/components/ui/button";
import { ConfigApp } from "../config/app";
import { InputField } from "@/app/lib/components/input-field";
import { VoiceSection } from "../config/components/voice";
import { ChatWidgetSection } from "../config/components/project";
enablePatches();
@ -599,6 +604,8 @@ export function WorkflowEditor({
onRevertToLive,
onProjectToolsUpdated,
onDataSourcesUpdated,
onProjectConfigUpdated,
chatWidgetHost,
}: {
projectId: string;
dataSources: WithStringId<z.infer<typeof DataSource>>[];
@ -616,6 +623,8 @@ export function WorkflowEditor({
onRevertToLive: () => void;
onProjectToolsUpdated?: () => void;
onDataSourcesUpdated?: () => void;
onProjectConfigUpdated?: () => void;
chatWidgetHost: string;
}) {
const [state, dispatch] = useReducer(reducer, {
@ -652,6 +661,19 @@ export function WorkflowEditor({
// Modal state for revert confirmation
const { isOpen: isRevertModalOpen, onOpen: onRevertModalOpen, onClose: onRevertModalClose } = useDisclosure();
// Modal state for settings
const { isOpen: isSettingsModalOpen, onOpen: onSettingsModalOpen, onClose: onSettingsModalClose } = useDisclosure();
// Modal state for phone/Twilio configuration
const { isOpen: isPhoneModalOpen, onOpen: onPhoneModalOpen, onClose: onPhoneModalClose } = useDisclosure();
// Modal state for chat widget configuration
const { isOpen: isChatWidgetModalOpen, onOpen: onChatWidgetModalOpen, onClose: onChatWidgetModalClose } = useDisclosure();
// Project name state
const [localProjectName, setLocalProjectName] = useState<string>(projectConfig.name || '');
const [projectNameError, setProjectNameError] = useState<string | null>(null);
// Load agent order from localStorage on mount
// useEffect(() => {
@ -877,10 +899,39 @@ export function WorkflowEditor({
}
}, [state.present.workflow, state.present.pendingChanges, processQueue, state]);
// Sync project name when projectConfig changes
useEffect(() => {
setLocalProjectName(projectConfig.name || '');
}, [projectConfig.name]);
function handlePlaygroundClick() {
setIsInitialState(false);
}
const validateProjectName = (value: string) => {
if (value.length === 0) {
setProjectNameError("Project name cannot be empty");
return false;
}
setProjectNameError(null);
return true;
};
const handleProjectNameChange = async (value: string) => {
setLocalProjectName(value);
if (validateProjectName(value)) {
try {
await updateProjectName(projectId, value);
// Trigger refresh of project config to update all references to project name
onProjectConfigUpdated?.();
} catch (error) {
setProjectNameError("Failed to update project name");
console.error('Failed to update project name:', error);
}
}
};
return (
<EntitySelectionContext.Provider value={{
onSelectAgent: handleSelectAgent,
@ -890,6 +941,20 @@ export function WorkflowEditor({
<div className="flex flex-col h-full relative">
<div className="shrink-0 flex justify-between items-center pb-6">
<div className="workflow-version-selector flex items-center gap-4 px-2 text-gray-800 dark:text-gray-100">
{/* Project Name Editor */}
<div className="flex flex-col min-w-0 max-w-xs">
<InputField
type="text"
value={localProjectName}
onChange={handleProjectNameChange}
error={projectNameError}
placeholder="Project name..."
className="text-lg font-semibold"
/>
</div>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
<WorkflowIcon size={16} />
<div className="flex items-center gap-2">
{state.present.publishing && <Spinner size="sm" />}
@ -979,16 +1044,52 @@ export function WorkflowEditor({
>
<RedoIcon size={16} />
</button>
<Button
variant="solid"
size="md"
onPress={handlePublishWorkflow}
className="gap-2 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold text-sm"
startContent={<RocketIcon size={16} />}
data-tour-target="deploy"
>
Deploy
</Button>
<div className="flex">
<Button
variant="solid"
size="md"
onPress={handlePublishWorkflow}
className="gap-2 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold text-sm rounded-r-none"
startContent={<RocketIcon size={16} />}
data-tour-target="deploy"
>
Deploy
</Button>
<Dropdown>
<DropdownTrigger>
<Button
variant="solid"
size="md"
className="min-w-0 px-2 bg-green-600 hover:bg-green-700 border-l-1 border-green-500 text-white font-semibold text-sm rounded-l-none"
>
<ChevronDownIcon size={14} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Deploy actions">
<DropdownItem
key="settings"
startContent={<SettingsIcon size={16} />}
onPress={onSettingsModalOpen}
>
API & SDK
</DropdownItem>
<DropdownItem
key="phone"
startContent={<PhoneIcon size={16} />}
onPress={onPhoneModalOpen}
>
Phone
</DropdownItem>
<DropdownItem
key="chat-widget"
startContent={<MessageCircleIcon size={16} />}
onPress={onChatWidgetModalOpen}
>
Chat widget
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
<Button
variant="solid"
size="md"
@ -1195,6 +1296,66 @@ export function WorkflowEditor({
</ModalFooter>
</ModalContent>
</Modal>
{/* Settings Modal */}
<Modal
isOpen={isSettingsModalOpen}
onClose={onSettingsModalClose}
size="5xl"
scrollBehavior="inside"
>
<ModalContent className="h-[80vh]">
<ModalHeader className="flex flex-col gap-1">
API & SDK
</ModalHeader>
<ModalBody className="p-0">
<ConfigApp
projectId={projectId}
useChatWidget={USE_CHAT_WIDGET}
chatWidgetHost={chatWidgetHost}
/>
</ModalBody>
</ModalContent>
</Modal>
{/* Phone/Twilio Modal */}
<Modal
isOpen={isPhoneModalOpen}
onClose={onPhoneModalClose}
size="4xl"
scrollBehavior="inside"
>
<ModalContent className="h-[80vh]">
<ModalHeader className="flex flex-col gap-1">
Phone Configuration
</ModalHeader>
<ModalBody className="p-0">
<VoiceSection projectId={projectId} />
</ModalBody>
</ModalContent>
</Modal>
{/* Chat Widget Modal */}
<Modal
isOpen={isChatWidgetModalOpen}
onClose={onChatWidgetModalClose}
size="4xl"
scrollBehavior="inside"
>
<ModalContent className="h-[70vh]">
<ModalHeader className="flex flex-col gap-1">
Chat Widget
</ModalHeader>
<ModalBody className="p-0">
<div className="p-6">
<ChatWidgetSection
projectId={projectId}
chatWidgetHost={chatWidgetHost}
/>
</div>
</ModalBody>
</ModalContent>
</Modal>
</div>
</EntitySelectionContext.Provider>
);

View file

@ -1,7 +1,7 @@
'use client';
import { usePathname } from "next/navigation";
import Link from "next/link";
import { SettingsIcon, WorkflowIcon, PlayIcon, LucideIcon } from "lucide-react";
import { WorkflowIcon, PlayIcon, LucideIcon } from "lucide-react";
import MenuItem from "./components/menu-item";
interface NavLinkProps {
@ -51,13 +51,6 @@ export default function Menu({
icon={PlayIcon}
selected={pathname.startsWith(`/projects/${projectId}/test`)}
/>
<NavLink
href={`/projects/${projectId}/config`}
label="Settings"
collapsed={collapsed}
icon={SettingsIcon}
selected={pathname.startsWith(`/projects/${projectId}/config`)}
/>
</div>
);
}