Project modals (#190)

* added the project create and select modals

* Added logo and link to home page

* minor changes to side bar

* added import json and blank template options

* added website like home page

* change homepage to be more like website

* fixed landing page text box

* minor size changes to the home page

* added prebuilt agents section

* Minor changes to the prebuilt agent card

* removed the project selection and new project options from side bar

* fixed prebuilt agents

* fixed import json

* my assistants has pagination

* add dark mode support to home page

* addressed review comments

* increase build agents textbox lines

* fixed PR comments

* move prebuilt assistant under build view

* minor changes to home page

* minor changes to home page

* removed my assistants from side bar

* removed sidebar items

* fixed review comments

* fixed review comments

* Add "use client" directive to project-creation-utils.ts

This file uses localStorage and calls server actions directly, requiring client-side execution.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix import of pipeline agents

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
arkml 2025-08-11 23:46:30 +05:30 committed by GitHub
parent 10045c742d
commit 9a980f2f9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1092 additions and 164 deletions

View file

@ -20,6 +20,17 @@ const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
export async function listTemplates() {
const templatesArray = Object.entries(templates)
.filter(([key]) => key !== 'default') // Exclude the default template
.map(([key, template]) => ({
id: key,
...template
}));
return templatesArray;
}
export async function projectAuthCheck(projectId: string) {
if (!USE_AUTH) {
return;
@ -115,11 +126,12 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
const name = formData.get('name') as string | null;
const workflowJson = formData.get('workflowJson') as string;
const { agents, prompts, tools, startAgent } = Workflow.parse(workflowJson);
const { agents, prompts, tools, pipelines, startAgent } = Workflow.parse(JSON.parse(workflowJson));
const response = await createBaseProject(name || 'Imported project', user, {
agents,
prompts,
tools,
pipelines,
startAgent,
lastUpdatedAt: (new Date()).toISOString(),
});

View file

@ -0,0 +1,78 @@
'use client';
import { forwardRef, TextareaHTMLAttributes } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Send } from 'lucide-react';
import clsx from 'clsx';
interface TextareaWithSendProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isSubmitting?: boolean;
submitDisabled?: boolean;
placeholder?: string;
className?: string;
rows?: number;
autoFocus?: boolean;
autoResize?: boolean;
}
export const TextareaWithSend = forwardRef<HTMLTextAreaElement, TextareaWithSendProps>(
({
value,
onChange,
onSubmit,
isSubmitting = false,
submitDisabled = false,
placeholder,
className,
rows = 3,
autoFocus = false,
autoResize = false,
...props
}, ref) => {
return (
<div className="relative">
<Textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={clsx("pr-14", className)}
rows={rows}
autoFocus={autoFocus}
autoResize={autoResize}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
{...props}
/>
<div className="absolute right-3 bottom-3">
<button
onClick={onSubmit}
disabled={isSubmitting || submitDisabled || !value.trim()}
className={clsx(
"rounded-full p-2 transition-all duration-200",
value.trim()
? "bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500",
isSubmitting ? "opacity-50" : "hover:scale-105 active:scale-95"
)}
>
{isSubmitting ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
) : (
<Send size={18} />
)}
</button>
</div>
</div>
);
}
);
TextareaWithSend.displayName = 'TextareaWithSend';

View file

@ -4,17 +4,14 @@ import { Project } from "../lib/types/project_types";
import { useEffect, useState } from "react";
import { z } from "zod";
import { listProjects } from "../actions/project_actions";
import { USE_MULTIPLE_PROJECTS } from "@/app/lib/feature_flags";
import { SearchProjects } from "./components/search-projects";
import { CreateProject } from "./components/create-project";
import clsx from 'clsx';
import { BuildAssistantSection } from "./components/build-assistant-section";
export default function App() {
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isProjectPaneOpen, setIsProjectPaneOpen] = useState(false);
const [defaultName, setDefaultName] = useState('Assistant 1');
const getNextAssistantNumber = (projects: z.infer<typeof Project>[]) => {
const untitledProjects = projects
.map(p => p.name)
@ -32,22 +29,16 @@ export default function App() {
let ignore = false;
async function fetchProjects() {
setIsLoading(true);
const projects = await listProjects();
if (!ignore) {
const sortedProjects = [...projects].sort((a, b) =>
const sortedProjects = [...projects].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setProjects(sortedProjects);
setIsLoading(false);
const nextNumber = getNextAssistantNumber(sortedProjects);
const newDefaultName = `Assistant ${nextNumber}`;
setDefaultName(newDefaultName);
// Default open project pane if there is at least one project
if (sortedProjects.length > 0) {
setIsProjectPaneOpen(true);
}
}
}
@ -59,29 +50,8 @@ export default function App() {
}, []);
return (
<div className="flex gap-8 px-16 pt-8">
{USE_MULTIPLE_PROJECTS && isProjectPaneOpen && (
<div className="w-1/3 min-w-[300px] max-w-[400px]">
<SearchProjects
projects={projects}
isLoading={isLoading}
heading="Select existing assistant"
className="h-full"
onClose={() => setIsProjectPaneOpen(false)}
/>
</div>
)}
<div className={clsx(
"flex-1",
!isProjectPaneOpen && "w-full",
)}>
<CreateProject
defaultName={defaultName}
onOpenProjectPane={() => setIsProjectPaneOpen(true)}
isProjectPaneOpen={isProjectPaneOpen}
/>
</div>
<div className="min-h-screen bg-white dark:bg-gray-900">
<BuildAssistantSection defaultName={defaultName} />
</div>
);
}
}

View file

@ -0,0 +1,467 @@
'use client';
import { useState, useRef, useEffect } from "react";
import { listTemplates, listProjects } from "@/app/actions/project_actions";
import { createProjectWithOptions, createProjectFromJsonWithOptions, createProjectFromTemplate } from "../lib/project-creation-utils";
import { useRouter } from 'next/navigation';
import clsx from 'clsx';
import Image from 'next/image';
import mascotImage from '/public/mascot.png';
import { Button } from "@/components/ui/button";
import { Upload, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { TextareaWithSend } from "@/app/components/ui/textarea-with-send";
import { Workflow } from '../../lib/types/workflow_types';
import { PictureImg } from '@/components/ui/picture-img';
import { Tabs, Tab } from "@/components/ui/tabs";
import { Project } from "@/app/lib/types/project_types";
import { z } from "zod";
import Link from 'next/link';
interface BuildAssistantSectionProps {
defaultName: string;
}
const ITEMS_PER_PAGE = 6;
export function BuildAssistantSection({ defaultName }: BuildAssistantSectionProps) {
const [userPrompt, setUserPrompt] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [promptError, setPromptError] = useState<string | null>(null);
const [importLoading, setImportLoading] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [templates, setTemplates] = useState<any[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [templatesError, setTemplatesError] = useState<string | null>(null);
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [selectedTab, setSelectedTab] = useState('new');
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const totalPages = Math.ceil(projects.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentProjects = projects.slice(startIndex, endIndex);
// Extract unique tools from template - using same approach as ToolkitCard
const getUniqueTools = (template: any) => {
if (!template.tools) return [];
const uniqueToolsMap = new Map();
template.tools.forEach((tool: any) => {
if (!uniqueToolsMap.has(tool.name)) {
// Include all tools, following the same pattern as Composio toolkit cards
const toolData = {
name: tool.name,
isComposio: tool.isComposio,
isLibrary: tool.isLibrary,
logo: tool.isComposio && tool.composioData?.logo ? tool.composioData.logo : null,
};
uniqueToolsMap.set(tool.name, toolData);
}
});
return Array.from(uniqueToolsMap.values()).filter(tool => tool.logo); // Only show tools with logos like ToolkitCard
};
const fetchTemplates = async () => {
setTemplatesLoading(true);
setTemplatesError(null);
try {
const templatesArray = await listTemplates();
setTemplates(templatesArray);
} catch (error) {
console.error('Error fetching templates:', error);
setTemplatesError(error instanceof Error ? error.message : 'Failed to load templates');
} finally {
setTemplatesLoading(false);
}
};
// Handle template selection
const handleTemplateSelect = async (templateId: string, templateName: string) => {
await createProjectFromTemplate(templateId, templateName, router);
};
const fetchProjects = async () => {
setProjectsLoading(true);
try {
const projectsList = await listProjects();
const sortedProjects = [...projectsList].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setProjects(sortedProjects);
} catch (error) {
console.error('Error fetching projects:', error);
} finally {
setProjectsLoading(false);
}
};
useEffect(() => {
fetchTemplates();
fetchProjects();
}, []);
const handleCreateAssistant = async () => {
if (!userPrompt.trim()) {
setPromptError("Prompt cannot be empty");
return;
}
setIsCreating(true);
try {
await createProjectWithOptions({
name: defaultName,
prompt: userPrompt,
router,
onError: (error) => {
console.error('Error creating project:', error);
}
});
} finally {
setIsCreating(false);
}
};
// Import JSON functionality
const handleImportJsonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
setTimeout(() => {
fileInputRef.current?.click();
}, 0);
}
};
// Handle file selection
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
setImportLoading(true);
setImportError(null);
try {
const text = await file.text();
let parsed = Workflow.safeParse(JSON.parse(text));
if (!parsed.success) {
setImportError('Invalid workflow JSON: ' + JSON.stringify(parsed.error.issues));
setImportLoading(false);
return;
}
// Create project from imported JSON
await createProjectFromJsonWithOptions({
name: defaultName,
workflowJson: text,
router,
onError: (error) => {
setImportError(error instanceof Error ? error.message : String(error));
}
});
} catch (err) {
setImportError('Invalid JSON: ' + (err instanceof Error ? err.message : String(err)));
} finally {
setImportLoading(false);
}
};
// Handle "I'll build it myself" button
const handleBuildItMyself = async () => {
await createProjectWithOptions({
name: defaultName,
template: 'default',
router
});
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={handleFileChange}
/>
<div className="px-8 py-16">
<div className="max-w-7xl mx-auto">
{/* Main Headline */}
<div className="text-center mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-gray-100 mb-6 leading-tight">
Build <span className="bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent">Rowboats</span> that Work for You
</h1>
</div>
{/* Tabs Section */}
<div className="max-w-5xl mx-auto">
<div className="p-6 pb-0">
<Tabs defaultSelectedKey="new" selectedKey={selectedTab} onSelectionChange={(key) => setSelectedTab(key as string)} className="w-full">
<Tab key="new" title="New Assistant">
<div className="pt-4">
<div className="flex items-center gap-12">
{/* Mascot */}
<div className="flex-shrink-0">
<Image
src={mascotImage}
alt="Rowboat Mascot"
width={200}
height={200}
className="w-[200px] h-[200px] object-contain"
/>
</div>
{/* Input Area */}
<div className="flex-1">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Hey! What agents can I build for you?
</h2>
<div className="relative group flex flex-col">
<TextareaWithSend
value={userPrompt}
onChange={(value) => {
setUserPrompt(value);
setPromptError(null);
}}
onSubmit={handleCreateAssistant}
isSubmitting={isCreating}
placeholder="Example: build me an AI SDR agent..."
className={clsx(
"w-full rounded-lg p-3 border border-gray-200 dark:border-gray-700",
"bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750",
"focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20",
"placeholder:text-gray-400 dark:placeholder:text-gray-500 transition-all duration-200",
"text-base text-gray-900 dark:text-gray-100 min-h-32",
promptError && "border-red-500 focus:ring-red-500/20",
!userPrompt && "animate-pulse border-2 border-indigo-500/40 dark:border-indigo-400/40 shadow-lg shadow-indigo-500/20 dark:shadow-indigo-400/20"
)}
rows={3}
autoFocus
autoResize
/>
{promptError && (
<p className="text-sm text-red-500 m-0 mt-2">
{promptError}
</p>
)}
</div>
{/* Separation line with OR */}
<div className="relative my-3">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white dark:bg-gray-800 px-3 text-gray-500 dark:text-gray-400">OR</span>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 justify-start">
<Button
variant="primary"
size="sm"
onClick={handleImportJsonClick}
type="button"
startContent={<Upload size={14} />}
className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
disabled={importLoading}
>
{importLoading ? 'Importing...' : 'Import JSON'}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleBuildItMyself}
type="button"
className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
>
Go to Builder
</Button>
</div>
{importError && (
<p className="text-sm text-red-500 mt-2">
{importError}
</p>
)}
</div>
</div>
</div>
</div>
</Tab>
<Tab key="existing" title="My Assistants">
<div className="pt-4">
<div className="h-96 flex flex-col bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-4">
{projectsLoading ? (
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
Loading assistants...
</div>
) : projects.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
No assistants found. Create your first assistant to get started!
</div>
) : (
<>
<div className="flex-1 overflow-y-auto">
<div className="space-y-2">
{currentProjects.map((project) => (
<Link
key={project._id}
href={`/projects/${project._id}/workflow`}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all group hover:shadow-sm"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-green-500 opacity-75 flex-shrink-0"></div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
{project.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Created {new Date(project.createdAt).toLocaleDateString()} Last updated {new Date(project.lastUpdatedAt).toLocaleDateString()}
</div>
</div>
</div>
</div>
<div className="flex-shrink-0 ml-4">
<div className="text-xs text-gray-400 dark:text-gray-500">
</div>
</div>
</Link>
))}
</div>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className={clsx(
"p-2 rounded-md transition-colors",
"text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400",
"disabled:opacity-50 disabled:cursor-not-allowed",
"hover:bg-gray-100 dark:hover:bg-gray-700"
)}
>
<ChevronLeftIcon className="w-5 h-5" />
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Page {currentPage} of {totalPages} ({projects.length} assistants)
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className={clsx(
"p-2 rounded-md transition-colors",
"text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400",
"disabled:opacity-50 disabled:cursor-not-allowed",
"hover:bg-gray-100 dark:hover:bg-gray-700"
)}
>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
)}
</>
)}
</div>
</div>
</Tab>
</Tabs>
</div>
</div>
{/* Pre-built Assistants Section - Only show for New Assistant tab */}
{selectedTab === 'new' && (
<div className="max-w-5xl mx-auto mt-16">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-left mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Pre-built Assistants
</h2>
</div>
{templatesLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
Loading pre-built assistants...
</div>
) : templatesError ? (
<div className="flex items-center justify-center py-12 text-sm text-red-500 dark:text-red-400">
Error: {templatesError}
</div>
) : templates.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
No pre-built assistants available
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => (
<button
key={template.id}
onClick={() => handleTemplateSelect(template.id, template.name)}
className="block p-4 border border-gray-200 dark:border-gray-700 rounded-xl hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all group hover:shadow-md text-left"
>
<div className="space-y-2">
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1">
{template.name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{template.description}
</div>
{/* Tool logos */}
{(() => {
const tools = getUniqueTools(template);
return tools.length > 0 && (
<div className="flex items-center gap-2 mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500">
Tools:
</div>
<div className="flex items-center gap-1">
{tools.slice(0, 4).map((tool) => (
tool.logo && (
<PictureImg
key={tool.name}
src={tool.logo}
alt={`${tool.name} logo`}
className="w-4 h-4 rounded-sm object-cover flex-shrink-0"
title={tool.name}
/>
)
))}
{tools.length > 4 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
+{tools.length - 4}
</span>
)}
</div>
</div>
);
})()}
<div className="flex items-center justify-between mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500">
</div>
<div className="w-2 h-2 rounded-full bg-blue-500 opacity-75"></div>
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
</>
);
}

View file

@ -1,7 +1,7 @@
'use client';
import { useEffect, useState, useRef } from "react";
import { createProject, createProjectFromWorkflowJson } from "@/app/actions/project_actions";
import { createProjectWithOptions, createProjectFromJsonWithOptions } from "../lib/project-creation-utils";
import { useRouter, useSearchParams } from 'next/navigation';
import clsx from 'clsx';
import { Textarea } from "@/components/ui/textarea";
@ -128,9 +128,10 @@ interface CreateProjectProps {
defaultName: string;
onOpenProjectPane: () => void;
isProjectPaneOpen: boolean;
hideHeader?: boolean;
}
export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpen }: CreateProjectProps) {
export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpen, hideHeader = false }: CreateProjectProps) {
const [selectedTab, setSelectedTab] = useState<TabState>(TabType.Describe);
const [customPrompt, setCustomPrompt] = useState("");
const [name, setName] = useState(defaultName);
@ -168,26 +169,17 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
if ((urlPrompt || urlTemplate) && !importLoading && !autoCreateLoading) {
setAutoCreateLoading(true);
try {
const formData = new FormData();
// If template is provided, use it
if (urlTemplate) {
formData.append('template', urlTemplate);
}
const response = await createProject(formData);
if ('id' in response) {
// Store prompt in localStorage if provided
if (urlPrompt) {
localStorage.setItem(`project_prompt_${response.id}`, urlPrompt);
await createProjectWithOptions({
name: 'New Assistant', // Default name for auto-creation
template: urlTemplate || undefined,
prompt: urlPrompt || undefined,
router,
onError: (error) => {
// Auto-creation failed, show the form instead
setBillingError(error instanceof Error ? error.message : String(error));
setAutoCreateLoading(false);
}
router.push(`/projects/${response.id}/workflow`);
} else {
// Auto-creation failed, show the form instead
setBillingError(response.billingError);
setAutoCreateLoading(false);
}
});
} catch (error) {
console.error('Error auto-creating project:', error);
setAutoCreateLoading(false);
@ -288,43 +280,47 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
try {
if (importedJson) {
// Use imported JSON
const formData = new FormData();
formData.append('name', name);
formData.append('workflowJson', importedJson);
const response = await createProjectFromWorkflowJson(formData);
if ('id' in response) {
router.push(`/projects/${response.id}/workflow`);
} else {
setBillingError(response.billingError);
}
await createProjectFromJsonWithOptions({
name,
workflowJson: importedJson,
router,
onError: (error) => {
setBillingError(error instanceof Error ? error.message : String(error));
}
});
return;
}
if (!customPrompt.trim()) {
setPromptError("Prompt cannot be empty");
return;
}
const newFormData = new FormData();
newFormData.append('name', name);
// If template is provided via URL, use it
if (urlTemplate) {
newFormData.append('template', urlTemplate);
}
const response = await createProject(newFormData);
if ('id' in response) {
if (customPrompt) {
localStorage.setItem(`project_prompt_${response.id}`, customPrompt);
await createProjectWithOptions({
name,
template: urlTemplate || undefined,
prompt: customPrompt,
router,
onError: (error) => {
setBillingError(error instanceof Error ? error.message : String(error));
}
router.push(`/projects/${response.id}/workflow`);
} else {
setBillingError(response.billingError);
}
});
} catch (error) {
console.error('Error creating project:', error);
}
}
async function handleSubmitWithTemplate(template: string) {
await createProjectWithOptions({
name,
template,
router,
onError: (error) => {
setBillingError(error instanceof Error ? error.message : String(error));
}
});
}
return (
<>
<input
@ -344,7 +340,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
!USE_MULTIPLE_PROJECTS && "px-24",
USE_MULTIPLE_PROJECTS && "px-8"
)}>
{USE_MULTIPLE_PROJECTS && (
{USE_MULTIPLE_PROJECTS && !hideHeader && (
<>
<div className="px-4 pt-4 pb-6 flex justify-between items-center">
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
@ -477,17 +473,40 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
</p>
)}
</div>
{/* Import JSON button always below the main input, left-aligned, when no file is selected */}
<div className="mt-2">
{/* Separation line with OR */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white dark:bg-gray-900 px-3 text-gray-500 dark:text-gray-400">OR</span>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 justify-start">
<Button
variant="secondary"
variant="primary"
size="sm"
onClick={handleImportJsonClick}
type="button"
startContent={<Upload size={16} />}
startContent={<Upload size={14} />}
className="bg-white dark:bg-white text-gray-900 hover:bg-gray-50 border border-gray-300 dark:border-gray-300"
>
Import JSON
</Button>
<Button
variant="primary"
size="sm"
onClick={() => {
handleSubmitWithTemplate('default');
}}
type="button"
className="bg-white dark:bg-white text-gray-900 hover:bg-gray-50 border border-gray-300 dark:border-gray-300"
>
I&apos;ll build it myself
</Button>
</div>
</>
)}

View file

@ -0,0 +1,145 @@
'use client';
import { useState, useEffect } from "react";
import { useRouter } from 'next/navigation';
import { listTemplates } from "@/app/actions/project_actions";
import { createProjectFromTemplate } from "../lib/project-creation-utils";
import { PictureImg } from '@/components/ui/picture-img';
interface TemplatesSectionProps {}
export function TemplatesSection({}: TemplatesSectionProps) {
const [templates, setTemplates] = useState<any[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [templatesError, setTemplatesError] = useState<string | null>(null);
const router = useRouter();
// Extract unique tools from template - using same approach as ToolkitCard
const getUniqueTools = (template: any) => {
if (!template.tools) return [];
const uniqueToolsMap = new Map();
template.tools.forEach((tool: any) => {
if (!uniqueToolsMap.has(tool.name)) {
// Include all tools, following the same pattern as Composio toolkit cards
const toolData = {
name: tool.name,
isComposio: tool.isComposio,
isLibrary: tool.isLibrary,
logo: tool.isComposio && tool.composioData?.logo ? tool.composioData.logo : null,
};
uniqueToolsMap.set(tool.name, toolData);
}
});
return Array.from(uniqueToolsMap.values()).filter(tool => tool.logo); // Only show tools with logos like ToolkitCard
};
const fetchTemplates = async () => {
setTemplatesLoading(true);
setTemplatesError(null);
try {
const templatesArray = await listTemplates();
setTemplates(templatesArray);
} catch (error) {
console.error('Error fetching templates:', error);
setTemplatesError(error instanceof Error ? error.message : 'Failed to load templates');
} finally {
setTemplatesLoading(false);
}
};
// Handle template selection
const handleTemplateSelect = async (templateId: string, templateName: string) => {
await createProjectFromTemplate(templateId, templateName, router);
};
useEffect(() => {
fetchTemplates();
}, []);
return (
<div className="h-screen flex flex-col px-8 py-8 overflow-hidden">
<div className="max-w-7xl mx-auto w-full flex flex-col h-full">
<div className="px-6 pb-4">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Pre-built agents
</h2>
</div>
<div className="px-6 flex-1 overflow-hidden">
{templatesLoading ? (
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
Loading templates...
</div>
) : templatesError ? (
<div className="flex items-center justify-center h-full text-sm text-red-500 dark:text-red-400">
Error: {templatesError}
</div>
) : templates.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
No templates available
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
{templates.map((template) => (
<button
key={template.id}
onClick={() => handleTemplateSelect(template.id, template.name)}
className="block p-4 border border-gray-200 dark:border-gray-700 rounded-xl hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all group hover:shadow-md text-left"
>
<div className="space-y-2">
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1">
{template.name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{template.description}
</div>
{/* Tool logos */}
{(() => {
const tools = getUniqueTools(template);
return tools.length > 0 && (
<div className="flex items-center gap-2 mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500">
Tools:
</div>
<div className="flex items-center gap-1">
{tools.slice(0, 4).map((tool) => (
tool.logo && (
<PictureImg
key={tool.name}
src={tool.logo}
alt={`${tool.name} logo`}
className="w-4 h-4 rounded-sm object-cover flex-shrink-0"
title={tool.name}
/>
)
))}
{tools.length > 4 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
+{tools.length - 4}
</span>
)}
</div>
</div>
);
})()}
<div className="flex items-center justify-between mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500">
</div>
<div className="w-2 h-2 rounded-full bg-blue-500 opacity-75"></div>
</div>
</div>
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,14 +1,15 @@
'use client';
import { useEffect, useState } from 'react';
import Link from "next/link";
import Image from "next/image";
import logoImage from '/public/logo-only.png';
import { usePathname } from "next/navigation";
import { Tooltip } from "@heroui/react";
import { Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
import { UserButton } from "@/app/lib/components/user_button";
import {
SettingsIcon,
WorkflowIcon,
PlayIcon,
FolderOpenIcon,
ChevronLeftIcon,
ChevronRightIcon,
Moon,
@ -18,9 +19,13 @@ import {
LogsIcon
} from "lucide-react";
import { getProjectConfig } from "@/app/actions/project_actions";
import { createProjectWithOptions } from "../../lib/project-creation-utils";
import { useTheme } from "@/app/providers/theme-provider";
import { USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
import { useHelpModal } from "@/app/providers/help-modal-provider";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { TextareaWithSend } from "@/app/components/ui/textarea-with-send";
interface SidebarProps {
projectId?: string;
@ -35,10 +40,15 @@ const COLLAPSED_ICON_SIZE = 20; // DO NOT CHANGE THIS
export default function Sidebar({ projectId, useAuth, collapsed = false, onToggleCollapse, useBilling }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [projectName, setProjectName] = useState<string>("Select Project");
const [assistantName, setAssistantName] = useState("");
const [assistantPrompt, setAssistantPrompt] = useState("");
const [isCreatingAssistant, setIsCreatingAssistant] = useState(false);
const isProjectsRoute = pathname === '/projects';
const { theme, toggleTheme } = useTheme();
const { showHelpModal } = useHelpModal();
const { isOpen: isCreateModalOpen, onOpen: onCreateModalOpen, onClose: onCreateModalClose } = useDisclosure();
useEffect(() => {
async function fetchProjectName() {
@ -55,6 +65,35 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
fetchProjectName();
}, [projectId, isProjectsRoute]);
const handleCreateAssistant = async () => {
if (!assistantPrompt.trim()) return;
setIsCreatingAssistant(true);
try {
await createProjectWithOptions({
name: assistantName || 'New Assistant',
prompt: assistantPrompt,
router,
onSuccess: () => {
onCreateModalClose();
},
onError: (error) => {
console.error('Error creating assistant:', error);
}
});
} finally {
setIsCreatingAssistant(false);
}
};
const handleCreateModalClose = () => {
setAssistantName("");
setAssistantPrompt("");
onCreateModalClose();
};
const navItems = [
{
href: 'workflow',
@ -82,6 +121,13 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
}
];
const projectsNavItems: Array<{
href: string;
label: string;
icon: any;
requiresProject: boolean;
}> = [];
const handleStartTour = () => {
localStorage.removeItem('user_product_tour_completed');
window.location.reload();
@ -91,84 +137,86 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
<>
<aside className={`${collapsed ? 'w-16' : 'w-60'} bg-transparent flex flex-col h-full transition-all duration-300`}>
<div className="flex flex-col grow">
{!isProjectsRoute && (
<>
{/* Project Selector */}
<div className="p-3 border-b border-zinc-100 dark:border-zinc-800">
<Tooltip content={collapsed ? projectName : "Change project"} showArrow placement="right">
<Link
href="/projects"
className={`
flex items-center rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800/50 transition-all
${collapsed ? 'justify-center py-4' : 'gap-3 px-4 py-2.5'}
`}
>
<FolderOpenIcon
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
className="text-zinc-500 dark:text-zinc-400 transition-all duration-200"
/>
{!collapsed && (
<span className="text-sm font-medium truncate">
{projectName}
</span>
)}
</Link>
</Tooltip>
</div>
{/* Rowboat Logo */}
<div className="p-3 border-b border-zinc-100 dark:border-zinc-800">
<Tooltip content={collapsed ? "Rowboat" : ""} showArrow placement="right">
<Link
href="/projects"
className={`
w-full flex items-center justify-center rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800/50 transition-all
${collapsed ? 'py-3' : 'gap-3 px-4 py-2.5 justify-start'}
`}
>
<Image
src={logoImage}
alt="Rowboat"
width={collapsed ? 24 : 24}
height={collapsed ? 24 : 24}
className="rounded-full transition-all duration-200 flex-shrink-0"
/>
{!collapsed && (
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Rowboat
</span>
)}
</Link>
</Tooltip>
</div>
{/* Project-specific navigation Items */}
{projectId && <nav className="p-3 space-y-4">
{navItems.map((item) => {
const Icon = item.icon;
const fullPath = `/projects/${projectId}/${item.href}`;
const isActive = pathname.startsWith(fullPath);
const isDisabled = isProjectsRoute && item.requiresProject;
return (
<Tooltip
key={item.href}
content={collapsed ? item.label : ""}
showArrow
placement="right"
{/* Navigation Items */}
<nav className="p-3 space-y-4">
{!isProjectsRoute && projectId && (
// Project-specific navigation
navItems.map((item) => {
const Icon = item.icon;
const fullPath = `/projects/${projectId}/${item.href}`;
const isActive = pathname.startsWith(fullPath);
const isDisabled = isProjectsRoute && item.requiresProject;
return (
<Tooltip
key={item.href}
content={collapsed ? item.label : ""}
showArrow
placement="right"
>
<Link
href={isDisabled ? '#' : fullPath}
className={`
relative w-full rounded-md flex items-center
text-[15px] font-medium transition-all duration-200
${collapsed ? 'justify-center py-4' : 'px-2.5 py-3 gap-2.5'}
${isActive
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-l-2 border-indigo-600 dark:border-indigo-400'
: isDisabled
? 'text-zinc-300 dark:text-zinc-600 cursor-not-allowed'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-300'
}
${isDisabled ? 'pointer-events-none' : ''}
`}
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
>
<Link
href={isDisabled ? '#' : fullPath}
<Icon
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
className={`
relative w-full rounded-md flex items-center
text-[15px] font-medium transition-all duration-200
${collapsed ? 'justify-center py-4' : 'px-2.5 py-3 gap-2.5'}
${isActive
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-l-2 border-indigo-600 dark:border-indigo-400'
: isDisabled
? 'text-zinc-300 dark:text-zinc-600 cursor-not-allowed'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-300'
transition-all duration-200
${isDisabled
? 'text-zinc-300 dark:text-zinc-600'
: isActive
? 'text-indigo-600 dark:text-indigo-400'
: 'text-zinc-500 dark:text-zinc-400'
}
${isDisabled ? 'pointer-events-none' : ''}
`}
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
>
<Icon
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
className={`
transition-all duration-200
${isDisabled
? 'text-zinc-300 dark:text-zinc-600'
: isActive
? 'text-indigo-600 dark:text-indigo-400'
: 'text-zinc-500 dark:text-zinc-400'
}
`}
/>
{!collapsed && (
<span>{item.label}</span>
)}
</Link>
</Tooltip>
);
})}
</nav>}
</>
)}
/>
{!collapsed && (
<span>{item.label}</span>
)}
</Link>
</Tooltip>
);
})
)}
</nav>
</div>
{/* Bottom section */}
@ -242,6 +290,74 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
</div>
</div>
</aside>
{/* Create Assistant Modal */}
<Modal
isOpen={isCreateModalOpen}
onClose={handleCreateModalClose}
size="2xl"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
Create New Assistant
</ModalHeader>
<ModalBody>
<div className="space-y-4">
{/* Assistant Name Input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Assistant Name
</label>
<input
type="text"
value={assistantName}
onChange={(e) => setAssistantName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Assistant 1"
/>
</div>
{/* Assistant Description/Prompt */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
What do you want to build?
</label>
<TextareaWithSend
value={assistantPrompt}
onChange={setAssistantPrompt}
onSubmit={handleCreateAssistant}
isSubmitting={isCreatingAssistant}
placeholder="Example: Create a customer support assistant that can handle product inquiries and returns"
className="w-full min-h-[120px] border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autoFocus
/>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
In the next step, our AI copilot will create agents for you, complete with mock-tools.
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="secondary"
onClick={handleCreateModalClose}
disabled={isCreatingAssistant}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreateAssistant}
disabled={isCreatingAssistant || !assistantPrompt.trim()}
>
{isCreatingAssistant ? "Creating..." : "Create Assistant"}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { createProject, createProjectFromWorkflowJson } from "@/app/actions/project_actions";
export interface CreateProjectOptions {
name: string;
template?: string;
prompt?: string;
router: any; // NextJS router instance
onSuccess?: (projectId: string) => void;
onError?: (error: any) => void;
}
export interface CreateProjectFromJsonOptions {
name: string;
workflowJson: string;
router: any; // NextJS router instance
onSuccess?: (projectId: string) => void;
onError?: (error: any) => void;
}
/**
* Consolidated function to create a project with consistent error handling and navigation
*/
export async function createProjectWithOptions(options: CreateProjectOptions): Promise<void> {
try {
const formData = new FormData();
formData.append('name', options.name);
if (options.template) {
formData.append('template', options.template);
}
const response = await createProject(formData);
if ('id' in response) {
// Store prompt in localStorage if provided
if (options.prompt?.trim()) {
localStorage.setItem(`project_prompt_${response.id}`, options.prompt);
}
// Call success callback if provided
if (options.onSuccess) {
options.onSuccess(response.id);
}
// Navigate to workflow page
options.router.push(`/projects/${response.id}/workflow`);
} else {
// Handle error response
const error = (response as any).billingError || 'Failed to create project';
if (options.onError) {
options.onError(error);
} else {
throw new Error(error);
}
}
} catch (error) {
console.error('Error creating project:', error);
if (options.onError) {
options.onError(error);
} else {
throw error;
}
}
}
/**
* Consolidated function to create a project from JSON workflow
*/
export async function createProjectFromJsonWithOptions(options: CreateProjectFromJsonOptions): Promise<void> {
try {
const formData = new FormData();
formData.append('name', options.name);
formData.append('workflowJson', options.workflowJson);
const response = await createProjectFromWorkflowJson(formData);
if ('id' in response) {
// Call success callback if provided
if (options.onSuccess) {
options.onSuccess(response.id);
}
// Navigate to workflow page
options.router.push(`/projects/${response.id}/workflow`);
} else {
// Handle error response
const error = (response as any).billingError || 'Failed to create project';
if (options.onError) {
options.onError(error);
} else {
throw new Error(error);
}
}
} catch (error) {
console.error('Error creating project from JSON:', error);
if (options.onError) {
options.onError(error);
} else {
throw error;
}
}
}
/**
* Consolidated function to create a project from template selection
*/
export async function createProjectFromTemplate(
templateId: string,
templateName: string,
router: any,
onError?: (error: any) => void
): Promise<void> {
return createProjectWithOptions({
name: templateName,
template: templateId,
router,
onError
});
}