mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 00:46:23 +02:00
commit
1ade0a8df6
23 changed files with 1163 additions and 379 deletions
|
|
@ -269,7 +269,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
||||||
toolQuery={toolQuery}
|
toolQuery={toolQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 px-1">
|
<div className="shrink-0 px-0 pb-0">
|
||||||
{responseError && (
|
{responseError && (
|
||||||
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm">
|
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm">
|
||||||
<p className="text-red-600 dark:text-red-400">{responseError}</p>
|
<p className="text-red-600 dark:text-red-400">{responseError}</p>
|
||||||
|
|
@ -322,6 +322,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
||||||
dispatch: (action: WorkflowDispatch) => void;
|
dispatch: (action: WorkflowDispatch) => void;
|
||||||
isInitialState?: boolean;
|
isInitialState?: boolean;
|
||||||
dataSources?: z.infer<typeof DataSource>[];
|
dataSources?: z.infer<typeof DataSource>[];
|
||||||
|
activePanel: 'playground' | 'copilot';
|
||||||
|
onTogglePanel: () => void;
|
||||||
}>(({
|
}>(({
|
||||||
projectId,
|
projectId,
|
||||||
workflow,
|
workflow,
|
||||||
|
|
@ -329,6 +331,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
||||||
dispatch,
|
dispatch,
|
||||||
isInitialState = false,
|
isInitialState = false,
|
||||||
dataSources,
|
dataSources,
|
||||||
|
activePanel,
|
||||||
|
onTogglePanel,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [copilotKey, setCopilotKey] = useState(0);
|
const [copilotKey, setCopilotKey] = useState(0);
|
||||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||||
|
|
@ -365,8 +369,34 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
||||||
<Panel
|
<Panel
|
||||||
variant="copilot"
|
variant="copilot"
|
||||||
tourTarget="copilot"
|
tourTarget="copilot"
|
||||||
icon={<Sparkles className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />}
|
title={
|
||||||
title="Skipper"
|
<div className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg p-1 bg-blue-50/70 dark:bg-blue-900/30">
|
||||||
|
<button
|
||||||
|
onClick={onTogglePanel}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
|
activePanel === 'copilot'
|
||||||
|
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
|
||||||
|
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">✨</span>
|
||||||
|
Build
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onTogglePanel}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
|
activePanel === 'playground'
|
||||||
|
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
|
||||||
|
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">💬</span>
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
subtitle="Build your assistant"
|
subtitle="Build your assistant"
|
||||||
rightActions={
|
rightActions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -364,56 +364,6 @@ export function ToolConfig({
|
||||||
<span className="text-sm font-medium">Changes saved ✓</span>
|
<span className="text-sm font-medium">Changes saved ✓</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Tool Type Section */}
|
|
||||||
{!tool.isLibrary && <div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 mt-1">
|
|
||||||
{tool.isMcp ? (
|
|
||||||
<ImportIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
|
||||||
) : tool.isComposio ? (
|
|
||||||
<Zap className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
|
||||||
) : (
|
|
||||||
<Globe className="w-5 h-5 text-green-600 dark:text-green-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
|
|
||||||
How this tool runs
|
|
||||||
</h3>
|
|
||||||
{tool.isMcp && <div className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<p>This tool is powered by the <span className="font-medium text-blue-700 dark:text-blue-300">{tool.mcpServerName}</span> MCP server.</p>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
MCP (Model Context Protocol) tools are external services that provide additional capabilities to your agent.
|
|
||||||
</p>
|
|
||||||
</div>}
|
|
||||||
{ tool.isComposio && <div className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<p>This tool is powered by <span className="font-medium text-purple-700 dark:text-purple-300">Composio</span></p>
|
|
||||||
{tool.composioData?.toolkitName && (
|
|
||||||
<span className="text-xs bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 px-2 py-1 rounded-full">
|
|
||||||
{tool.composioData.toolkitName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Composio provides pre-built integrations with popular services and APIs.
|
|
||||||
</p>
|
|
||||||
</div>}
|
|
||||||
{ tool.isWebhook && <div className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<div className="flex items-center gap-1 mb-1">
|
|
||||||
<p>This tool is invoked using the webhook configured in <Link href={`/projects/${projectId}/config`} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 font-medium underline decoration-green-300 hover:decoration-green-500 transition-colors">project settings</Link></p>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Webhook tools make HTTP requests to your configured endpoint when called by the agent.
|
|
||||||
</p>
|
|
||||||
</div>}
|
|
||||||
{ !tool.isMcp && !tool.isComposio && !tool.isWebhook && <div className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<p>This is a placeholder tool that should be mocked.</p>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{/* Identity Section */}
|
{/* Identity Section */}
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<UserIcon className="w-5 h-5 text-indigo-500" />}
|
icon={<UserIcon className="w-5 h-5 text-indigo-500" />}
|
||||||
|
|
@ -570,6 +520,47 @@ export function ToolConfig({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Tool Type Section */}
|
||||||
|
{!tool.isLibrary && <div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{tool.isMcp ? (
|
||||||
|
<ImportIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
) : tool.isComposio ? (
|
||||||
|
<Zap className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<Globe className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
|
||||||
|
How this tool runs
|
||||||
|
</h3>
|
||||||
|
{tool.isMcp && <div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<p>This tool is powered by the <span className="font-medium text-blue-700 dark:text-blue-300">{tool.mcpServerName}</span> MCP server.</p>
|
||||||
|
</div>}
|
||||||
|
{ tool.isComposio && <div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p>This tool is powered by <span className="font-medium text-purple-700 dark:text-purple-300">Composio</span></p>
|
||||||
|
{tool.composioData?.toolkitName && (
|
||||||
|
<span className="text-xs bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 px-2 py-1 rounded-full">
|
||||||
|
{tool.composioData.toolkitName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{ tool.isWebhook && <div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<p>This tool is invoked using the webhook configured in <Link href={`/projects/${projectId}/config`} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 font-medium underline decoration-green-300 hover:decoration-green-500 transition-colors">project settings</Link></p>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{ !tool.isMcp && !tool.isComposio && !tool.isWebhook && <div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<p>This is a placeholder tool that should be mocked.</p>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,15 @@ const commonCronExamples = [
|
||||||
{ label: "Monthly on the 1st at midnight", value: "0 0 1 * *" },
|
{ label: "Monthly on the 1st at midnight", value: "0 0 1 * *" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CreateRecurringJobRuleForm({ projectId }: { projectId: string }) {
|
export function CreateRecurringJobRuleForm({
|
||||||
|
projectId,
|
||||||
|
onBack,
|
||||||
|
hasExistingTriggers = true
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
hasExistingTriggers?: boolean;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [messages, setMessages] = useState<FormMessage[]>([
|
const [messages, setMessages] = useState<FormMessage[]>([
|
||||||
|
|
@ -89,7 +97,11 @@ export function CreateRecurringJobRuleForm({ projectId }: { projectId: string })
|
||||||
input: { messages: convertedMessages },
|
input: { messages: convertedMessages },
|
||||||
cron: cronExpression,
|
cron: cronExpression,
|
||||||
});
|
});
|
||||||
router.push(`/projects/${projectId}/manage-triggers?tab=recurring`);
|
if (onBack) {
|
||||||
|
onBack();
|
||||||
|
} else {
|
||||||
|
router.push(`/projects/${projectId}/manage-triggers?tab=recurring`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create recurring job rule:", error);
|
console.error("Failed to create recurring job rule:", error);
|
||||||
alert("Failed to create recurring job rule");
|
alert("Failed to create recurring job rule");
|
||||||
|
|
@ -102,11 +114,23 @@ export function CreateRecurringJobRuleForm({ projectId }: { projectId: string })
|
||||||
<Panel
|
<Panel
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={`/projects/${projectId}/manage-triggers?tab=recurring`}>
|
{hasExistingTriggers && onBack ? (
|
||||||
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
startContent={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
) : hasExistingTriggers ? (
|
||||||
|
<Link href={`/projects/${projectId}/manage-triggers?tab=recurring`}>
|
||||||
|
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
CREATE RECURRING JOB RULE
|
CREATE RECURRING JOB RULE
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { listRecurringJobRules, deleteRecurringJobRule } from "@/app/actions/rec
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface";
|
import { ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface";
|
||||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||||
import { PlusIcon, Trash2 } from "lucide-react";
|
import { PlusIcon, Trash2, ArrowLeftIcon } from "lucide-react";
|
||||||
|
import { CreateRecurringJobRuleForm } from "./create-recurring-job-rule-form";
|
||||||
|
|
||||||
type ListedItem = z.infer<typeof ListedRecurringRuleItem>;
|
type ListedItem = z.infer<typeof ListedRecurringRuleItem>;
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
|
||||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||||
const [deletingRule, setDeletingRule] = useState<string | null>(null);
|
const [deletingRule, setDeletingRule] = useState<string | null>(null);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
|
||||||
|
|
||||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||||
const res = await listRecurringJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
const res = await listRecurringJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||||
|
|
@ -39,6 +41,12 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
|
||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [fetchPage]);
|
}, [fetchPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && items.length === 0 && !showCreateForm) {
|
||||||
|
setShowCreateForm(true);
|
||||||
|
}
|
||||||
|
}, [loading, items.length, showCreateForm]);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
|
@ -49,6 +57,24 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}, [cursor, fetchPage]);
|
}, [cursor, fetchPage]);
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setShowCreateForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToList = () => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
// Reload the list in case new triggers were created
|
||||||
|
const reload = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetchPage(null);
|
||||||
|
setItems(res.items);
|
||||||
|
setCursor(res.nextCursor);
|
||||||
|
setHasMore(Boolean(res.nextCursor));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteRule = async (ruleId: string) => {
|
const handleDeleteRule = async (ruleId: string) => {
|
||||||
if (!window.confirm('Are you sure you want to delete this recurring trigger?')) {
|
if (!window.confirm('Are you sure you want to delete this recurring trigger?')) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -125,6 +151,10 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
|
||||||
return cron;
|
return cron;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showCreateForm) {
|
||||||
|
return <CreateRecurringJobRuleForm projectId={projectId} onBack={handleBackToList} hasExistingTriggers={items.length > 0} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={
|
title={
|
||||||
|
|
@ -134,11 +164,14 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
|
||||||
}
|
}
|
||||||
rightActions={
|
rightActions={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={`/projects/${projectId}/manage-triggers/recurring/new`}>
|
<Button
|
||||||
<Button size="sm" className="whitespace-nowrap" startContent={<PlusIcon className="w-4 h-4" />}>
|
size="sm"
|
||||||
New Recurring Trigger
|
className="whitespace-nowrap"
|
||||||
</Button>
|
startContent={<PlusIcon className="w-4 h-4" />}
|
||||||
</Link>
|
onClick={handleCreateNew}
|
||||||
|
>
|
||||||
|
New Recurring Trigger
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,12 @@ export function TriggersTab({ projectId }: { projectId: string }) {
|
||||||
}
|
}
|
||||||
}, [showCreateFlow, loadTriggers]);
|
}, [showCreateFlow, loadTriggers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !error && triggers.length === 0 && !showCreateFlow) {
|
||||||
|
setShowCreateFlow(true);
|
||||||
|
}
|
||||||
|
}, [loading, error, triggers.length, showCreateFlow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// No-op: trigger names are now derived from slug locally
|
// No-op: trigger names are now derived from slug locally
|
||||||
}, [triggers]);
|
}, [triggers]);
|
||||||
|
|
@ -457,14 +463,16 @@ export function TriggersTab({ projectId }: { projectId: string }) {
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Select a Toolkit to Create Trigger
|
Select a Toolkit to Create Trigger
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
{triggers.length > 0 && (
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={handleBackToList}
|
variant="secondary"
|
||||||
startContent={<ArrowLeftIcon className="w-4 h-4" />}
|
onClick={handleBackToList}
|
||||||
className="whitespace-nowrap"
|
startContent={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
>
|
className="whitespace-nowrap"
|
||||||
Back to Triggers
|
>
|
||||||
</Button>
|
Back to Triggers
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectComposioToolkit
|
<SelectComposioToolkit
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ type FormMessage = {
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CreateScheduledJobRuleForm({ projectId }: { projectId: string }) {
|
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [messages, setMessages] = useState<FormMessage[]>([
|
const [messages, setMessages] = useState<FormMessage[]>([
|
||||||
|
|
@ -90,7 +90,11 @@ export function CreateScheduledJobRuleForm({ projectId }: { projectId: string })
|
||||||
input: { messages: convertedMessages },
|
input: { messages: convertedMessages },
|
||||||
scheduledTime: scheduledTimeString,
|
scheduledTime: scheduledTimeString,
|
||||||
});
|
});
|
||||||
router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`);
|
if (onBack) {
|
||||||
|
onBack();
|
||||||
|
} else {
|
||||||
|
router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create scheduled job rule:", error);
|
console.error("Failed to create scheduled job rule:", error);
|
||||||
alert("Failed to create scheduled job rule");
|
alert("Failed to create scheduled job rule");
|
||||||
|
|
@ -105,11 +109,17 @@ export function CreateScheduledJobRuleForm({ projectId }: { projectId: string })
|
||||||
<Panel
|
<Panel
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={`/projects/${projectId}/manage-triggers?tab=scheduled`}>
|
{hasExistingTriggers && onBack ? (
|
||||||
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
|
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap" onClick={onBack}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
) : hasExistingTriggers ? (
|
||||||
|
<Link href={`/projects/${projectId}/manage-triggers?tab=scheduled`}>
|
||||||
|
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
CREATE SCHEDULED JOB RULE
|
CREATE SCHEDULED JOB RULE
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { z } from "zod";
|
||||||
import { ListedRuleItem } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
|
import { ListedRuleItem } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
|
||||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||||
import { PlusIcon, Trash2 } from "lucide-react";
|
import { PlusIcon, Trash2 } from "lucide-react";
|
||||||
|
import { CreateScheduledJobRuleForm } from "./create-scheduled-job-rule-form";
|
||||||
|
|
||||||
type ListedItem = z.infer<typeof ListedRuleItem>;
|
type ListedItem = z.infer<typeof ListedRuleItem>;
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
|
||||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||||
const [deletingRule, setDeletingRule] = useState<string | null>(null);
|
const [deletingRule, setDeletingRule] = useState<string | null>(null);
|
||||||
|
const [showCreateFlow, setShowCreateFlow] = useState(false);
|
||||||
|
|
||||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||||
const res = await listScheduledJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
const res = await listScheduledJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||||
|
|
@ -39,6 +41,12 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
|
||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [fetchPage]);
|
}, [fetchPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && items.length === 0 && !showCreateFlow) {
|
||||||
|
setShowCreateFlow(true);
|
||||||
|
}
|
||||||
|
}, [loading, items.length, showCreateFlow]);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
|
@ -67,6 +75,29 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setShowCreateFlow(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToList = () => {
|
||||||
|
setShowCreateFlow(false);
|
||||||
|
// Reload the list to show any newly created triggers
|
||||||
|
const loadTriggers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetchPage(null);
|
||||||
|
setItems(response.items);
|
||||||
|
setCursor(response.nextCursor);
|
||||||
|
setHasMore(Boolean(response.nextCursor));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error loading triggers:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTriggers();
|
||||||
|
};
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
const groups: Record<string, ListedItem[]> = {
|
const groups: Record<string, ListedItem[]> = {
|
||||||
Today: [],
|
Today: [],
|
||||||
|
|
@ -103,6 +134,10 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showCreateFlow) {
|
||||||
|
return <CreateScheduledJobRuleForm projectId={projectId} onBack={handleBackToList} hasExistingTriggers={items.length > 0} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={
|
title={
|
||||||
|
|
@ -112,11 +147,9 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
|
||||||
}
|
}
|
||||||
rightActions={
|
rightActions={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={`/projects/${projectId}/manage-triggers/scheduled/new`}>
|
<Button size="sm" className="whitespace-nowrap" startContent={<PlusIcon className="w-4 h-4" />} onClick={handleCreateNew}>
|
||||||
<Button size="sm" className="whitespace-nowrap" startContent={<PlusIcon className="w-4 h-4" />}>
|
New One-time Trigger
|
||||||
New One-time Trigger
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ export function App({
|
||||||
onPanelClick,
|
onPanelClick,
|
||||||
triggerCopilotChat,
|
triggerCopilotChat,
|
||||||
isLiveWorkflow,
|
isLiveWorkflow,
|
||||||
|
activePanel,
|
||||||
|
onTogglePanel,
|
||||||
|
onMessageSent,
|
||||||
}: {
|
}: {
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -25,6 +28,9 @@ export function App({
|
||||||
onPanelClick?: () => void;
|
onPanelClick?: () => void;
|
||||||
triggerCopilotChat?: (message: string) => void;
|
triggerCopilotChat?: (message: string) => void;
|
||||||
isLiveWorkflow: boolean;
|
isLiveWorkflow: boolean;
|
||||||
|
activePanel: 'playground' | 'copilot';
|
||||||
|
onTogglePanel: () => void;
|
||||||
|
onMessageSent?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [counter, setCounter] = useState<number>(0);
|
const [counter, setCounter] = useState<number>(0);
|
||||||
const [showDebugMessages, setShowDebugMessages] = useState<boolean>(true);
|
const [showDebugMessages, setShowDebugMessages] = useState<boolean>(true);
|
||||||
|
|
@ -56,8 +62,34 @@ export function App({
|
||||||
className={`${hidden ? 'hidden' : 'block'}`}
|
className={`${hidden ? 'hidden' : 'block'}`}
|
||||||
variant="playground"
|
variant="playground"
|
||||||
tourTarget="playground"
|
tourTarget="playground"
|
||||||
icon={<MessageCircle className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
|
title={
|
||||||
title="Chat"
|
<div className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg p-1 bg-blue-50/70 dark:bg-blue-900/30">
|
||||||
|
<button
|
||||||
|
onClick={onTogglePanel}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
|
activePanel === 'copilot'
|
||||||
|
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
|
||||||
|
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">✨</span>
|
||||||
|
Build
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onTogglePanel}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
|
activePanel === 'playground'
|
||||||
|
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
|
||||||
|
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">💬</span>
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
subtitle="Chat with your assistant"
|
subtitle="Chat with your assistant"
|
||||||
rightActions={
|
rightActions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -112,6 +144,7 @@ export function App({
|
||||||
showDebugMessages={showDebugMessages}
|
showDebugMessages={showDebugMessages}
|
||||||
triggerCopilotChat={triggerCopilotChat}
|
triggerCopilotChat={triggerCopilotChat}
|
||||||
isLiveWorkflow={isLiveWorkflow}
|
isLiveWorkflow={isLiveWorkflow}
|
||||||
|
onMessageSent={onMessageSent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export function Chat({
|
||||||
showJsonMode = false,
|
showJsonMode = false,
|
||||||
triggerCopilotChat,
|
triggerCopilotChat,
|
||||||
isLiveWorkflow,
|
isLiveWorkflow,
|
||||||
|
onMessageSent,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
workflow: z.infer<typeof Workflow>;
|
workflow: z.infer<typeof Workflow>;
|
||||||
|
|
@ -31,6 +32,7 @@ export function Chat({
|
||||||
showJsonMode?: boolean;
|
showJsonMode?: boolean;
|
||||||
triggerCopilotChat?: (message: string) => void;
|
triggerCopilotChat?: (message: string) => void;
|
||||||
isLiveWorkflow: boolean;
|
isLiveWorkflow: boolean;
|
||||||
|
onMessageSent?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const conversationId = useRef<string | null>(null);
|
const conversationId = useRef<string | null>(null);
|
||||||
const [messages, setMessages] = useState<z.infer<typeof Message>[]>([]);
|
const [messages, setMessages] = useState<z.infer<typeof Message>[]>([]);
|
||||||
|
|
@ -158,6 +160,11 @@ export function Chat({
|
||||||
setMessages(updatedMessages);
|
setMessages(updatedMessages);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLastInteracted(true);
|
setIsLastInteracted(true);
|
||||||
|
|
||||||
|
// Mark playground as tested when user sends a message
|
||||||
|
if (onMessageSent) {
|
||||||
|
onMessageSent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean up event source on component unmount
|
// clean up event source on component unmount
|
||||||
|
|
@ -425,7 +432,7 @@ export function Chat({
|
||||||
<ChevronDownIcon className="w-5 h-5" strokeWidth={2.2} />
|
<ChevronDownIcon className="w-5 h-5" strokeWidth={2.2} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="bg-white dark:bg-zinc-900 pt-4 pb-2">
|
<div className="bg-white dark:bg-zinc-900 pt-4 pb-6">
|
||||||
{showSuccessMessage && (
|
{showSuccessMessage && (
|
||||||
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800
|
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800
|
||||||
rounded-lg flex gap-2 justify-between items-center">
|
rounded-lg flex gap-2 justify-between items-center">
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,8 @@ export function App({
|
||||||
|
|
||||||
function handleSetMode(mode: 'draft' | 'live') {
|
function handleSetMode(mode: 'draft' | 'live') {
|
||||||
setMode(mode);
|
setMode(mode);
|
||||||
|
// Reload data to ensure we have the latest workflow data for the current mode
|
||||||
|
reloadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevertToLive() {
|
async function handleRevertToLive() {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input } from "@heroui/react";
|
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input } from "@heroui/react";
|
||||||
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug } from "lucide-react";
|
import { Button as CustomButton } from "@/components/ui/button";
|
||||||
|
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon } from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { ProgressBar, ProgressStep } from "@/components/ui/progress-bar";
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
localProjectName: string;
|
localProjectName: string;
|
||||||
|
|
@ -12,16 +14,27 @@ interface TopBarProps {
|
||||||
publishing: boolean;
|
publishing: boolean;
|
||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
showCopySuccess: boolean;
|
showCopySuccess: boolean;
|
||||||
|
showBuildModeBanner: boolean;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
showCopilot: boolean;
|
activePanel: 'playground' | 'copilot';
|
||||||
|
hasAgentInstructionChanges: boolean;
|
||||||
|
hasPlaygroundTested: boolean;
|
||||||
|
hasPublished: boolean;
|
||||||
|
hasClickedUse: boolean;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onDownloadJSON: () => void;
|
onDownloadJSON: () => void;
|
||||||
onPublishWorkflow: () => void;
|
onPublishWorkflow: () => void;
|
||||||
onChangeMode: (mode: 'draft' | 'live') => void;
|
onChangeMode: (mode: 'draft' | 'live') => void;
|
||||||
onRevertToLive: () => void;
|
onRevertToLive: () => void;
|
||||||
onToggleCopilot: () => void;
|
onTogglePanel: () => void;
|
||||||
|
onUseAssistantClick: () => void;
|
||||||
|
onStartNewChatAndFocus: () => void;
|
||||||
|
onStartBuildTour?: () => void;
|
||||||
|
onStartTestTour?: () => void;
|
||||||
|
onStartPublishTour?: () => void;
|
||||||
|
onStartUseTour?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
|
|
@ -32,20 +45,47 @@ export function TopBar({
|
||||||
publishing,
|
publishing,
|
||||||
isLive,
|
isLive,
|
||||||
showCopySuccess,
|
showCopySuccess,
|
||||||
|
showBuildModeBanner,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
showCopilot,
|
activePanel,
|
||||||
|
hasAgentInstructionChanges,
|
||||||
|
hasPlaygroundTested,
|
||||||
|
hasPublished,
|
||||||
|
hasClickedUse,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onDownloadJSON,
|
onDownloadJSON,
|
||||||
onPublishWorkflow,
|
onPublishWorkflow,
|
||||||
onChangeMode,
|
onChangeMode,
|
||||||
onRevertToLive,
|
onRevertToLive,
|
||||||
onToggleCopilot,
|
onTogglePanel,
|
||||||
|
onUseAssistantClick,
|
||||||
|
onStartNewChatAndFocus,
|
||||||
|
onStartBuildTour,
|
||||||
|
onStartTestTour,
|
||||||
|
onStartPublishTour,
|
||||||
|
onStartUseTour,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const projectId = typeof (params as any).projectId === 'string' ? (params as any).projectId : (params as any).projectId?.[0];
|
const projectId = typeof (params as any).projectId === 'string' ? (params as any).projectId : (params as any).projectId?.[0];
|
||||||
|
// Progress bar steps with completion logic and current step detection
|
||||||
|
const step1Complete = hasAgentInstructionChanges;
|
||||||
|
const step2Complete = hasPlaygroundTested && hasAgentInstructionChanges;
|
||||||
|
const step3Complete = hasPublished && hasPlaygroundTested && hasAgentInstructionChanges;
|
||||||
|
const step4Complete = hasClickedUse && hasPublished && hasPlaygroundTested && hasAgentInstructionChanges;
|
||||||
|
|
||||||
|
// Determine current step (first incomplete step)
|
||||||
|
const currentStep = !step1Complete ? 1 : !step2Complete ? 2 : !step3Complete ? 3 : !step4Complete ? 4 : null;
|
||||||
|
|
||||||
|
const progressSteps: ProgressStep[] = [
|
||||||
|
{ id: 1, label: "Build: Ask the copilot to create your assistant. Add tools and connect data sources.", completed: step1Complete, isCurrent: currentStep === 1 },
|
||||||
|
{ id: 2, label: "Test: Test out your assistant by chatting with it. Use 'Fix' and 'Explain' to improve it.", completed: step2Complete, isCurrent: currentStep === 2 },
|
||||||
|
{ id: 3, label: "Publish: Make it live with the Publish button. You can always switch back to draft.", completed: step3Complete, isCurrent: currentStep === 3 },
|
||||||
|
{ id: 4, label: "Use: Click the 'Use Assistant' button to chat, set triggers (like emails), or connect via API.", completed: step4Complete, isCurrent: currentStep === 4 },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm border border-zinc-200 dark:border-zinc-800 px-5 py-2">
|
<div className="rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm border border-zinc-200 dark:border-zinc-800 px-5 py-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
|
|
@ -70,106 +110,156 @@ export function TopBar({
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "max-w-xs",
|
base: "max-w-xs",
|
||||||
input: "text-base font-semibold px-2",
|
input: "text-base font-semibold px-2",
|
||||||
inputWrapper: "min-h-[28px] h-[28px] border-gray-200 dark:border-gray-700 px-0"
|
inputWrapper: "min-h-[36px] h-[36px] border-gray-200 dark:border-gray-700 px-0"
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
|
{/* Show divider and CTA only in live view */}
|
||||||
|
{isLive && <div className="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>}
|
||||||
<div className="flex items-center gap-2">
|
{isLive ? (
|
||||||
{publishing && <Spinner size="sm" />}
|
<Button
|
||||||
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
|
variant="solid"
|
||||||
<RadioIcon size={16} />
|
size="md"
|
||||||
Live workflow
|
onPress={() => onChangeMode('draft')}
|
||||||
</div>}
|
className="gap-2 px-4 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 font-medium text-sm border border-gray-200 dark:border-gray-600 shadow-sm"
|
||||||
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
|
startContent={<PenLine size={16} />}
|
||||||
<PenLine size={16} />
|
>
|
||||||
Draft workflow
|
Switch to draft
|
||||||
</div>}
|
</Button>
|
||||||
|
) : null}
|
||||||
{/* Download JSON icon button, with tooltip, to the left of the menu */}
|
|
||||||
<Tooltip content="Download Assistant JSON">
|
|
||||||
<button
|
|
||||||
onClick={onDownloadJSON}
|
|
||||||
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
|
||||||
aria-label="Download JSON"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<DownloadIcon size={20} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{showCopySuccess && <div className="flex items-center gap-2">
|
|
||||||
<div className="text-green-500">Copied to clipboard</div>
|
{/* Progress Bar - Center */}
|
||||||
</div>}
|
<div className="flex-1 flex justify-center">
|
||||||
|
<ProgressBar
|
||||||
|
steps={progressSteps}
|
||||||
|
onStepClick={(step) => {
|
||||||
|
if (step.id === 1 && onStartBuildTour) onStartBuildTour();
|
||||||
|
if (step.id === 2 && onStartTestTour) onStartTestTour();
|
||||||
|
if (step.id === 3 && onStartPublishTour) onStartPublishTour();
|
||||||
|
if (step.id === 4 && onStartUseTour) onStartUseTour();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isLive && <div className="flex items-center gap-2 absolute left-1/2 transform -translate-x-1/2">
|
{showCopySuccess && <div className="flex items-center gap-2 mr-4">
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
|
<div className="text-green-500">Copied to clipboard</div>
|
||||||
<AlertTriangle size={16} />
|
</div>}
|
||||||
This version is locked. Changes applied will not be reflected.
|
|
||||||
|
{showBuildModeBanner && <div className="flex items-center gap-2 mr-4">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<div className="text-blue-700 dark:text-blue-300 text-sm">
|
||||||
|
Switched to draft mode. You can now make changes to your workflow.
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
|
||||||
{!isLive && <>
|
{!isLive && <>
|
||||||
<button
|
<CustomButton
|
||||||
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
|
variant="primary"
|
||||||
title="Undo"
|
size="sm"
|
||||||
disabled={!canUndo}
|
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
className="bg-gray-50 text-gray-700 hover:bg-gray-100 disabled:bg-gray-25 disabled:text-gray-400"
|
||||||
|
showHoverContent={true}
|
||||||
|
hoverContent="Undo"
|
||||||
>
|
>
|
||||||
<UndoIcon size={16} />
|
<UndoIcon className="w-4 h-4" />
|
||||||
</button>
|
</CustomButton>
|
||||||
<button
|
<CustomButton
|
||||||
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
|
variant="primary"
|
||||||
title="Redo"
|
size="sm"
|
||||||
disabled={!canRedo}
|
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
className="bg-gray-50 text-gray-700 hover:bg-gray-100 disabled:bg-gray-25 disabled:text-gray-400"
|
||||||
|
showHoverContent={true}
|
||||||
|
hoverContent="Redo"
|
||||||
>
|
>
|
||||||
<RedoIcon size={16} />
|
<RedoIcon className="w-4 h-4" />
|
||||||
</button>
|
</CustomButton>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
{/* Deploy CTA - always visible */}
|
{/* Deploy CTA - always visible */}
|
||||||
<div className="flex">
|
<div className="flex items-center gap-3">
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
<Dropdown>
|
<>
|
||||||
<DropdownTrigger>
|
<Dropdown>
|
||||||
<Button
|
<DropdownTrigger>
|
||||||
variant="solid"
|
<Button
|
||||||
size="md"
|
variant="solid"
|
||||||
className="gap-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm"
|
size="md"
|
||||||
startContent={<Plug size={16} />}
|
className="gap-2 px-4 bg-blue-50 hover:bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-400 font-semibold text-sm border border-blue-200 dark:border-blue-700 shadow-sm"
|
||||||
>
|
startContent={<Plug size={16} />}
|
||||||
Use Assistant
|
onPress={onUseAssistantClick}
|
||||||
<ChevronDownIcon size={14} />
|
|
||||||
</Button>
|
|
||||||
</DropdownTrigger>
|
|
||||||
<DropdownMenu aria-label="Assistant access options">
|
|
||||||
<DropdownItem
|
|
||||||
key="api-sdk"
|
|
||||||
startContent={<SettingsIcon size={16} />}
|
|
||||||
onPress={() => { if (projectId) { router.push(`/projects/${projectId}/config`); } }}
|
|
||||||
>
|
|
||||||
API & SDK Settings
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem
|
|
||||||
key="manage-triggers"
|
|
||||||
startContent={<ZapIcon size={16} />}
|
|
||||||
onPress={() => { if (projectId) { router.push(`/projects/${projectId}/manage-triggers`); } }}
|
|
||||||
>
|
>
|
||||||
Manage Triggers
|
Use Assistant
|
||||||
</DropdownItem>
|
<ChevronDownIcon size={14} />
|
||||||
</DropdownMenu>
|
</Button>
|
||||||
</Dropdown>
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu aria-label="Assistant access options">
|
||||||
|
<DropdownItem
|
||||||
|
key="chat"
|
||||||
|
startContent={<MessageCircleIcon size={16} />}
|
||||||
|
onPress={() => {
|
||||||
|
onUseAssistantClick();
|
||||||
|
onStartNewChatAndFocus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chat with Assistant
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
key="api-sdk"
|
||||||
|
startContent={<SettingsIcon size={16} />}
|
||||||
|
onPress={() => {
|
||||||
|
onUseAssistantClick();
|
||||||
|
if (projectId) { router.push(`/projects/${projectId}/config`); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
API & SDK Settings
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
key="manage-triggers"
|
||||||
|
startContent={<ZapIcon size={16} />}
|
||||||
|
onPress={() => {
|
||||||
|
onUseAssistantClick();
|
||||||
|
if (projectId) { router.push(`/projects/${projectId}/manage-triggers`); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Triggers
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
{/* Live workflow label moved here */}
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
{publishing && <Spinner size="sm" />}
|
||||||
|
<div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<RadioIcon size={16} />
|
||||||
|
Live workflow
|
||||||
|
</div>
|
||||||
|
<Tooltip content="Download Assistant JSON">
|
||||||
|
<button
|
||||||
|
onClick={onDownloadJSON}
|
||||||
|
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
aria-label="Download JSON"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<DownloadIcon size={20} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex">
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
size="md"
|
size="md"
|
||||||
onPress={onPublishWorkflow}
|
onPress={onPublishWorkflow}
|
||||||
className="gap-2 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold text-sm rounded-r-none"
|
className="gap-2 px-4 bg-green-100 hover:bg-green-200 text-green-800 font-semibold text-sm rounded-r-none border border-green-300 shadow-sm"
|
||||||
startContent={<RocketIcon size={16} />}
|
startContent={<RocketIcon size={16} />}
|
||||||
data-tour-target="deploy"
|
data-tour-target="deploy"
|
||||||
>
|
>
|
||||||
|
|
@ -180,7 +270,7 @@ export function TopBar({
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
size="md"
|
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"
|
className="min-w-0 px-2 bg-green-100 hover:bg-green-200 text-green-800 rounded-l-none border border-l-0 border-green-300 shadow-sm"
|
||||||
>
|
>
|
||||||
<ChevronDownIcon size={14} />
|
<ChevronDownIcon size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -203,31 +293,34 @@ export function TopBar({
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moved draft/live labels and download button here */}
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
{publishing && <Spinner size="sm" />}
|
||||||
|
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<RadioIcon size={16} />
|
||||||
|
Live workflow
|
||||||
|
</div>}
|
||||||
|
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<PenLine size={16} />
|
||||||
|
Draft workflow
|
||||||
|
</div>}
|
||||||
|
<Tooltip content="Download Assistant JSON">
|
||||||
|
<button
|
||||||
|
onClick={onDownloadJSON}
|
||||||
|
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
aria-label="Download JSON"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<DownloadIcon size={20} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLive && <div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
onPress={() => onChangeMode('draft')}
|
|
||||||
className="gap-2 px-4 bg-gray-600 hover:bg-gray-700 text-white font-semibold text-sm"
|
|
||||||
startContent={<PenLine size={16} />}
|
|
||||||
>
|
|
||||||
Switch to draft
|
|
||||||
</Button>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{!isLive && <Button
|
|
||||||
variant="solid"
|
|
||||||
size="md"
|
|
||||||
onPress={onToggleCopilot}
|
|
||||||
className="gap-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm"
|
|
||||||
startContent={showCopilot ? null : <span className="text-indigo-300">✨</span>}
|
|
||||||
>
|
|
||||||
{showCopilot ? "Hide Skipper" : "Skipper"}
|
|
||||||
</Button>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -207,8 +207,8 @@ const ListItemWithMenu = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const StartLabel = () => (
|
const StartLabel = () => (
|
||||||
<div className="text-xs text-indigo-500 dark:text-indigo-400 bg-indigo-50/50 dark:bg-indigo-950/30 px-1.5 py-0.5 rounded">
|
<div className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded font-medium">
|
||||||
Start
|
START
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1175,7 +1175,7 @@ export const EntityList = forwardRef<
|
||||||
tourTarget="entity-prompts"
|
tourTarget="entity-prompts"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"h-full",
|
"h-full",
|
||||||
!expandedPanels.prompts && "h-[53px]!"
|
!expandedPanels.prompts && "h-[61px]!"
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
<div className={`${headerClasses} rounded-md transition-colors h-full`}>
|
<div className={`${headerClasses} rounded-md transition-colors h-full`}>
|
||||||
|
|
@ -1208,7 +1208,7 @@ export const EntityList = forwardRef<
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{expandedPanels.prompts && (
|
{expandedPanels.prompts && (
|
||||||
<div className="h-[calc(100%-53px)] overflow-y-auto">
|
<div className="h-[calc(100%-61px)] overflow-y-auto">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{prompts.length > 0 ? (
|
{prompts.length > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -1631,17 +1631,6 @@ const ComposioCard = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* More tools option */}
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 px-3 py-2 mt-1 text-xs text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 rounded transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedToolkitSlug(card.slug);
|
|
||||||
setShowToolsModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
|
||||||
<span>More tools</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ interface StateItem {
|
||||||
chatKey: number;
|
chatKey: number;
|
||||||
lastUpdatedAt: string;
|
lastUpdatedAt: string;
|
||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
|
agentInstructionsChanged: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
|
|
@ -73,6 +74,15 @@ interface State {
|
||||||
export type Action = {
|
export type Action = {
|
||||||
type: "update_workflow_name";
|
type: "update_workflow_name";
|
||||||
name: string;
|
name: string;
|
||||||
|
} | {
|
||||||
|
type: "switch_to_draft_due_to_changes";
|
||||||
|
} | {
|
||||||
|
type: "show_workflow_change_banner";
|
||||||
|
} | {
|
||||||
|
type: "clear_workflow_change_banner";
|
||||||
|
} | {
|
||||||
|
type: "set_is_live";
|
||||||
|
isLive: boolean;
|
||||||
} | {
|
} | {
|
||||||
type: "set_publishing";
|
type: "set_publishing";
|
||||||
publishing: boolean;
|
publishing: boolean;
|
||||||
|
|
@ -238,6 +248,19 @@ function reducer(state: State, action: Action): State {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "switch_to_draft_due_to_changes": {
|
||||||
|
newState = produce(state, draft => {
|
||||||
|
draft.present.isLive = false;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "set_is_live": {
|
||||||
|
newState = produce(state, draft => {
|
||||||
|
draft.present.isLive = action.isLive;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "set_saving": {
|
case "set_saving": {
|
||||||
newState = produce(state, draft => {
|
newState = produce(state, draft => {
|
||||||
draft.present.saving = action.saving;
|
draft.present.saving = action.saving;
|
||||||
|
|
@ -335,9 +358,6 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.selection = null;
|
draft.selection = null;
|
||||||
break;
|
break;
|
||||||
case "add_agent": {
|
case "add_agent": {
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let newAgentName = "New agent";
|
let newAgentName = "New agent";
|
||||||
if (draft.workflow?.agents.some((agent) => agent.name === newAgentName)) {
|
if (draft.workflow?.agents.some((agent) => agent.name === newAgentName)) {
|
||||||
newAgentName = `New agent ${draft.workflow.agents.filter((agent) =>
|
newAgentName = `New agent ${draft.workflow.agents.filter((agent) =>
|
||||||
|
|
@ -368,9 +388,6 @@ function reducer(state: State, action: Action): State {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "add_tool": {
|
case "add_tool": {
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let newToolName = "new_tool";
|
let newToolName = "new_tool";
|
||||||
if (draft.workflow?.tools.some((tool) => tool.name === newToolName)) {
|
if (draft.workflow?.tools.some((tool) => tool.name === newToolName)) {
|
||||||
newToolName = `new_tool_${draft.workflow.tools.filter((tool) =>
|
newToolName = `new_tool_${draft.workflow.tools.filter((tool) =>
|
||||||
|
|
@ -396,9 +413,6 @@ function reducer(state: State, action: Action): State {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "add_prompt": {
|
case "add_prompt": {
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let newPromptName = "New Variable";
|
let newPromptName = "New Variable";
|
||||||
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
|
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
|
||||||
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
|
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
|
||||||
|
|
@ -419,9 +433,6 @@ function reducer(state: State, action: Action): State {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "add_prompt_no_select": {
|
case "add_prompt_no_select": {
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let newPromptName = "New Variable";
|
let newPromptName = "New Variable";
|
||||||
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
|
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
|
||||||
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
|
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
|
||||||
|
|
@ -440,9 +451,6 @@ function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
// TODO: parameterize this instead of writing if else based on pipeline length (pipelineAgents.length)
|
// TODO: parameterize this instead of writing if else based on pipeline length (pipelineAgents.length)
|
||||||
case "add_pipeline": {
|
case "add_pipeline": {
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!draft.workflow.pipelines) {
|
if (!draft.workflow.pipelines) {
|
||||||
draft.workflow.pipelines = [];
|
draft.workflow.pipelines = [];
|
||||||
|
|
@ -521,9 +529,6 @@ function reducer(state: State, action: Action): State {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "delete_agent":
|
case "delete_agent":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Remove the agent
|
// Remove the agent
|
||||||
draft.workflow.agents = draft.workflow.agents.filter(
|
draft.workflow.agents = draft.workflow.agents.filter(
|
||||||
(agent) => agent.name !== action.name
|
(agent) => agent.name !== action.name
|
||||||
|
|
@ -568,9 +573,6 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "delete_tool":
|
case "delete_tool":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
draft.workflow.tools = draft.workflow.tools.filter(
|
draft.workflow.tools = draft.workflow.tools.filter(
|
||||||
(tool) => tool.name !== action.name
|
(tool) => tool.name !== action.name
|
||||||
);
|
);
|
||||||
|
|
@ -579,9 +581,6 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "delete_prompt":
|
case "delete_prompt":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
draft.workflow.prompts = draft.workflow.prompts.filter(
|
draft.workflow.prompts = draft.workflow.prompts.filter(
|
||||||
(prompt) => prompt.name !== action.name
|
(prompt) => prompt.name !== action.name
|
||||||
);
|
);
|
||||||
|
|
@ -590,9 +589,6 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "delete_pipeline":
|
case "delete_pipeline":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (draft.workflow.pipelines) {
|
if (draft.workflow.pipelines) {
|
||||||
// Find the pipeline to get its associated agents
|
// Find the pipeline to get its associated agents
|
||||||
const pipelineToDelete = draft.workflow.pipelines.find(
|
const pipelineToDelete = draft.workflow.pipelines.find(
|
||||||
|
|
@ -649,9 +645,6 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "update_pipeline": {
|
case "update_pipeline": {
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (draft.workflow.pipelines) {
|
if (draft.workflow.pipelines) {
|
||||||
draft.workflow.pipelines = draft.workflow.pipelines.map(pipeline =>
|
draft.workflow.pipelines = draft.workflow.pipelines.map(pipeline =>
|
||||||
pipeline.name === action.name ? { ...pipeline, ...action.pipeline } : pipeline
|
pipeline.name === action.name ? { ...pipeline, ...action.pipeline } : pipeline
|
||||||
|
|
@ -663,8 +656,9 @@ function reducer(state: State, action: Action): State {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "update_agent": {
|
case "update_agent": {
|
||||||
if (isLive) {
|
// Check if instructions are being changed
|
||||||
break;
|
if (action.agent.instructions !== undefined) {
|
||||||
|
draft.agentInstructionsChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// update agent data
|
// update agent data
|
||||||
|
|
@ -724,9 +718,6 @@ function reducer(state: State, action: Action): State {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "update_tool":
|
case "update_tool":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update tool data
|
// update tool data
|
||||||
draft.workflow.tools = draft.workflow.tools.map((tool) =>
|
draft.workflow.tools = draft.workflow.tools.map((tool) =>
|
||||||
|
|
@ -769,9 +760,6 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "update_prompt":
|
case "update_prompt":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update prompt data
|
// update prompt data
|
||||||
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
|
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
|
||||||
|
|
@ -814,9 +802,6 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "update_prompt_no_select":
|
case "update_prompt_no_select":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update prompt data
|
// update prompt data
|
||||||
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
|
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
|
||||||
|
|
@ -855,18 +840,12 @@ function reducer(state: State, action: Action): State {
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "toggle_agent":
|
case "toggle_agent":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
draft.workflow.agents = draft.workflow.agents.map(agent =>
|
draft.workflow.agents = draft.workflow.agents.map(agent =>
|
||||||
agent.name === action.name ? { ...agent, disabled: !agent.disabled } : agent
|
agent.name === action.name ? { ...agent, disabled: !agent.disabled } : agent
|
||||||
);
|
);
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
break;
|
break;
|
||||||
case "set_main_agent":
|
case "set_main_agent":
|
||||||
if (isLive) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
draft.workflow.startAgent = action.name;
|
draft.workflow.startAgent = action.name;
|
||||||
draft.pendingChanges = true;
|
draft.pendingChanges = true;
|
||||||
draft.chatKey++;
|
draft.chatKey++;
|
||||||
|
|
@ -955,6 +934,7 @@ export function WorkflowEditor({
|
||||||
chatKey: 0,
|
chatKey: 0,
|
||||||
lastUpdatedAt: workflow.lastUpdatedAt,
|
lastUpdatedAt: workflow.lastUpdatedAt,
|
||||||
isLive,
|
isLive,
|
||||||
|
agentInstructionsChanged: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -965,10 +945,47 @@ export function WorkflowEditor({
|
||||||
const saveQueue = useRef<z.infer<typeof Workflow>[]>([]);
|
const saveQueue = useRef<z.infer<typeof Workflow>[]>([]);
|
||||||
const saving = useRef(false);
|
const saving = useRef(false);
|
||||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||||
const [showCopilot, setShowCopilot] = useState(true);
|
const [activePanel, setActivePanel] = useState<'playground' | 'copilot'>('copilot');
|
||||||
const [copilotWidth, setCopilotWidth] = useState<number>(PANEL_RATIOS.copilot);
|
|
||||||
const [isInitialState, setIsInitialState] = useState(true);
|
const [isInitialState, setIsInitialState] = useState(true);
|
||||||
|
const [showBuildModeBanner, setShowBuildModeBanner] = useState(false);
|
||||||
|
const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<Action | null>(null);
|
||||||
|
const [configKey, setConfigKey] = useState(0);
|
||||||
|
const [lastWorkflowId, setLastWorkflowId] = useState<string | null>(null);
|
||||||
const [showTour, setShowTour] = useState(true);
|
const [showTour, setShowTour] = useState(true);
|
||||||
|
const [showBuildTour, setShowBuildTour] = useState(false);
|
||||||
|
const [showTestTour, setShowTestTour] = useState(false);
|
||||||
|
const [showPublishTour, setShowPublishTour] = useState(false);
|
||||||
|
const [showUseTour, setShowUseTour] = useState(false);
|
||||||
|
|
||||||
|
// Centralized mode transition handler
|
||||||
|
const handleModeTransition = useCallback((newMode: 'draft' | 'live', reason: 'publish' | 'view_live' | 'switch_draft' | 'modal_switch') => {
|
||||||
|
// Clear any open entity configs
|
||||||
|
dispatch({ type: "unselect_agent" });
|
||||||
|
|
||||||
|
// Set default panel based on mode
|
||||||
|
setActivePanel(newMode === 'live' ? 'playground' : 'copilot');
|
||||||
|
|
||||||
|
// Force component re-render
|
||||||
|
setConfigKey(prev => prev + 1);
|
||||||
|
|
||||||
|
// Handle mode-specific logic
|
||||||
|
if (reason === 'publish') {
|
||||||
|
// This will be handled by the publish function itself
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Direct mode switch
|
||||||
|
onChangeMode(newMode);
|
||||||
|
|
||||||
|
// If switching to draft mode, we need to ensure we have the correct draft data
|
||||||
|
// The parent component will update the workflow prop, but we need to wait for it
|
||||||
|
if (newMode === 'draft') {
|
||||||
|
// Force a workflow state reset when the workflow prop updates
|
||||||
|
setLastWorkflowId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onChangeMode]);
|
||||||
const copilotRef = useRef<{ handleUserMessage: (message: string) => void }>(null);
|
const copilotRef = useRef<{ handleUserMessage: (message: string) => void }>(null);
|
||||||
const entityListRef = useRef<{ openDataSourcesModal: () => void } | null>(null);
|
const entityListRef = useRef<{ openDataSourcesModal: () => void } | null>(null);
|
||||||
|
|
||||||
|
|
@ -987,6 +1004,75 @@ export function WorkflowEditor({
|
||||||
const [isEditingProjectName, setIsEditingProjectName] = useState<boolean>(false);
|
const [isEditingProjectName, setIsEditingProjectName] = useState<boolean>(false);
|
||||||
const [pendingProjectName, setPendingProjectName] = useState<string | null>(null);
|
const [pendingProjectName, setPendingProjectName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Build progress tracking - persists once set to true (guard SSR)
|
||||||
|
const [hasAgentInstructionChanges, setHasAgentInstructionChanges] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return localStorage.getItem(`agent_instructions_changed_${projectId}`) === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test progress tracking - persists once set to true (guard SSR)
|
||||||
|
const [hasPlaygroundTested, setHasPlaygroundTested] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return localStorage.getItem(`playground_tested_${projectId}`) === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish progress tracking - persists once set to true (guard SSR)
|
||||||
|
const [hasPublished, setHasPublished] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return localStorage.getItem(`has_published_${projectId}`) === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use progress tracking - persists once set to true (guard SSR)
|
||||||
|
const [hasClickedUse, setHasClickedUse] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return localStorage.getItem(`has_clicked_use_${projectId}`) === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to mark agent instructions as changed (persists in localStorage)
|
||||||
|
const markAgentInstructionsChanged = useCallback(() => {
|
||||||
|
if (!hasAgentInstructionChanges) {
|
||||||
|
setHasAgentInstructionChanges(true);
|
||||||
|
localStorage.setItem(`agent_instructions_changed_${projectId}`, 'true');
|
||||||
|
}
|
||||||
|
}, [hasAgentInstructionChanges, projectId]);
|
||||||
|
|
||||||
|
// Function to mark playground as tested (persists in localStorage)
|
||||||
|
const markPlaygroundTested = useCallback(() => {
|
||||||
|
if (!hasPlaygroundTested && hasAgentInstructionChanges) { // Only mark if step 1 is complete
|
||||||
|
setHasPlaygroundTested(true);
|
||||||
|
localStorage.setItem(`playground_tested_${projectId}`, 'true');
|
||||||
|
}
|
||||||
|
}, [hasPlaygroundTested, hasAgentInstructionChanges, projectId]);
|
||||||
|
|
||||||
|
// Function to mark as published (persists in localStorage)
|
||||||
|
const markAsPublished = useCallback(() => {
|
||||||
|
if (!hasPublished) {
|
||||||
|
setHasPublished(true);
|
||||||
|
localStorage.setItem(`has_published_${projectId}`, 'true');
|
||||||
|
}
|
||||||
|
}, [hasPublished, projectId]);
|
||||||
|
|
||||||
|
// Function to mark Use Assistant button as clicked (persists in localStorage)
|
||||||
|
const markUseAssistantClicked = useCallback(() => {
|
||||||
|
if (!hasClickedUse) {
|
||||||
|
setHasClickedUse(true);
|
||||||
|
localStorage.setItem(`has_clicked_use_${projectId}`, 'true');
|
||||||
|
}
|
||||||
|
}, [hasClickedUse, projectId]);
|
||||||
|
|
||||||
|
// Reference to start new chat function from playground
|
||||||
|
const startNewChatRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Function to start new chat and focus
|
||||||
|
const handleStartNewChatAndFocus = useCallback(() => {
|
||||||
|
if (startNewChatRef.current) {
|
||||||
|
startNewChatRef.current();
|
||||||
|
}
|
||||||
|
// Switch to playground (chat) mode and collapse left panel
|
||||||
|
setActivePanel('playground');
|
||||||
|
setIsLeftPanelCollapsed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load agent order from localStorage on mount
|
// Load agent order from localStorage on mount
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// const mode = isLive ? 'live' : 'draft';
|
// const mode = isLive ? 'live' : 'draft';
|
||||||
|
|
@ -1010,7 +1096,7 @@ export function WorkflowEditor({
|
||||||
|
|
||||||
// Function to trigger copilot chat
|
// Function to trigger copilot chat
|
||||||
const triggerCopilotChat = useCallback((message: string) => {
|
const triggerCopilotChat = useCallback((message: string) => {
|
||||||
setShowCopilot(true);
|
setActivePanel('copilot');
|
||||||
// Small delay to ensure copilot is mounted
|
// Small delay to ensure copilot is mounted
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copilotRef.current?.handleUserMessage(message);
|
copilotRef.current?.handleUserMessage(message);
|
||||||
|
|
@ -1028,14 +1114,14 @@ export function WorkflowEditor({
|
||||||
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
|
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
|
||||||
console.log('init project prompt', prompt);
|
console.log('init project prompt', prompt);
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
setShowCopilot(true);
|
setActivePanel('copilot');
|
||||||
}
|
}
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Hide copilot when switching to live mode
|
// Switch to playground when switching to live mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
setShowCopilot(false);
|
setActivePanel('playground');
|
||||||
}
|
}
|
||||||
}, [isLive]);
|
}, [isLive]);
|
||||||
|
|
||||||
|
|
@ -1053,6 +1139,13 @@ export function WorkflowEditor({
|
||||||
}
|
}
|
||||||
}, [state.present.workflow, state.present.pendingChanges]);
|
}, [state.present.workflow, state.present.pendingChanges]);
|
||||||
|
|
||||||
|
// Track agent instruction changes from copilot
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.present.agentInstructionsChanged) {
|
||||||
|
markAgentInstructionsChanged();
|
||||||
|
}
|
||||||
|
}, [state.present.agentInstructionsChanged, markAgentInstructionsChanged]);
|
||||||
|
|
||||||
function handleSelectAgent(name: string) {
|
function handleSelectAgent(name: string) {
|
||||||
dispatch({ type: "select_agent", name });
|
dispatch({ type: "select_agent", name });
|
||||||
}
|
}
|
||||||
|
|
@ -1093,15 +1186,15 @@ export function WorkflowEditor({
|
||||||
...agent,
|
...agent,
|
||||||
model: agent.model || defaultModel || "gpt-4.1"
|
model: agent.model || defaultModel || "gpt-4.1"
|
||||||
};
|
};
|
||||||
dispatch({ type: "add_agent", agent: agentWithModel });
|
dispatchGuarded({ type: "add_agent", agent: agentWithModel });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddTool(tool: Partial<z.infer<typeof WorkflowTool>> = {}) {
|
function handleAddTool(tool: Partial<z.infer<typeof WorkflowTool>> = {}) {
|
||||||
dispatch({ type: "add_tool", tool });
|
dispatchGuarded({ type: "add_tool", tool });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddPrompt(prompt: Partial<z.infer<typeof WorkflowPrompt>> = {}) {
|
function handleAddPrompt(prompt: Partial<z.infer<typeof WorkflowPrompt>> = {}) {
|
||||||
dispatch({ type: "add_prompt", prompt });
|
dispatchGuarded({ type: "add_prompt", prompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectPipeline(name: string) {
|
function handleSelectPipeline(name: string) {
|
||||||
|
|
@ -1109,7 +1202,7 @@ export function WorkflowEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddPipeline(pipeline: Partial<z.infer<typeof WorkflowPipeline>> = {}) {
|
function handleAddPipeline(pipeline: Partial<z.infer<typeof WorkflowPipeline>> = {}) {
|
||||||
dispatch({ type: "add_pipeline", pipeline, defaultModel });
|
dispatchGuarded({ type: "add_pipeline", pipeline, defaultModel });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeletePipeline(name: string) {
|
function handleDeletePipeline(name: string) {
|
||||||
|
|
@ -1130,12 +1223,12 @@ export function WorkflowEditor({
|
||||||
};
|
};
|
||||||
|
|
||||||
// First add the agent
|
// First add the agent
|
||||||
dispatch({ type: "add_agent", agent: agentWithModel });
|
dispatchGuarded({ type: "add_agent", agent: agentWithModel });
|
||||||
|
|
||||||
// Then add it to the pipeline
|
// Then add it to the pipeline
|
||||||
const pipeline = state.present.workflow.pipelines?.find(p => p.name === pipelineName);
|
const pipeline = state.present.workflow.pipelines?.find(p => p.name === pipelineName);
|
||||||
if (pipeline) {
|
if (pipeline) {
|
||||||
dispatch({
|
dispatchGuarded({
|
||||||
type: "update_pipeline",
|
type: "update_pipeline",
|
||||||
name: pipelineName,
|
name: pipelineName,
|
||||||
pipeline: {
|
pipeline: {
|
||||||
|
|
@ -1150,6 +1243,10 @@ export function WorkflowEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUpdateAgent(name: string, agent: Partial<z.infer<typeof WorkflowAgent>>) {
|
function handleUpdateAgent(name: string, agent: Partial<z.infer<typeof WorkflowAgent>>) {
|
||||||
|
// Check if instructions are being changed
|
||||||
|
if (agent.instructions !== undefined) {
|
||||||
|
markAgentInstructionsChanged();
|
||||||
|
}
|
||||||
dispatch({ type: "update_agent", name, agent });
|
dispatch({ type: "update_agent", name, agent });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1225,8 +1322,18 @@ export function WorkflowEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublishWorkflow() {
|
async function handlePublishWorkflow() {
|
||||||
await publishWorkflow(projectId, state.present.workflow);
|
dispatch({ type: 'set_publishing', publishing: true });
|
||||||
onChangeMode('live');
|
try {
|
||||||
|
await publishWorkflow(projectId, state.present.workflow);
|
||||||
|
markAsPublished(); // Mark step 3 as completed when user publishes
|
||||||
|
// Use centralized mode transition for publish
|
||||||
|
handleModeTransition('live', 'publish');
|
||||||
|
// reflect live mode both internally and externally in one go
|
||||||
|
dispatch({ type: 'set_is_live', isLive: true });
|
||||||
|
onChangeMode('live');
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: 'set_publishing', publishing: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRevertToLive() {
|
function handleRevertToLive() {
|
||||||
|
|
@ -1326,6 +1433,105 @@ export function WorkflowEditor({
|
||||||
setIsInitialState(false);
|
setIsInitialState(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Centralized draft switch for any workflow modification while in live mode
|
||||||
|
const ensureDraftForModify = useCallback(() => {
|
||||||
|
if (isLive && !state.present.publishing) {
|
||||||
|
onChangeMode('draft');
|
||||||
|
setShowBuildModeBanner(true);
|
||||||
|
setTimeout(() => setShowBuildModeBanner(false), 5000);
|
||||||
|
}
|
||||||
|
}, [isLive, state.present.publishing, onChangeMode]);
|
||||||
|
|
||||||
|
const WORKFLOW_MOD_ACTIONS = useRef(new Set([
|
||||||
|
'add_agent','add_tool','add_prompt','add_prompt_no_select','add_pipeline',
|
||||||
|
'update_agent','update_tool','update_prompt','update_prompt_no_select','update_pipeline',
|
||||||
|
'delete_agent','delete_tool','delete_prompt','delete_pipeline',
|
||||||
|
'toggle_agent','set_main_agent','reorder_agents','reorder_pipelines'
|
||||||
|
])).current;
|
||||||
|
|
||||||
|
const dispatchGuarded = useCallback((action: Action) => {
|
||||||
|
// Intercept workflow modifications in live mode before they reach the reducer
|
||||||
|
if (WORKFLOW_MOD_ACTIONS.has((action as any).type) && isLive && !state.present.publishing) {
|
||||||
|
setPendingAction(action);
|
||||||
|
setShowEditModal(true);
|
||||||
|
return; // Block the action - it never reaches the reducer
|
||||||
|
}
|
||||||
|
dispatch(action); // Allow the action to proceed
|
||||||
|
}, [WORKFLOW_MOD_ACTIONS, isLive, state.present.publishing, dispatch]);
|
||||||
|
|
||||||
|
// Simplified modal handlers
|
||||||
|
const handleSwitchToDraft = useCallback(() => {
|
||||||
|
setShowEditModal(false);
|
||||||
|
setPendingAction(null); // Don't apply the pending action
|
||||||
|
handleModeTransition('draft', 'modal_switch');
|
||||||
|
setShowBuildModeBanner(true);
|
||||||
|
setTimeout(() => setShowBuildModeBanner(false), 5000);
|
||||||
|
}, [handleModeTransition]);
|
||||||
|
|
||||||
|
const handleCancelEdit = useCallback(() => {
|
||||||
|
setShowEditModal(false);
|
||||||
|
setPendingAction(null);
|
||||||
|
// Force re-render of config components to reset form values
|
||||||
|
setConfigKey(prev => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Single useEffect for data synchronization
|
||||||
|
useEffect(() => {
|
||||||
|
// Only sync when workflow data actually changes
|
||||||
|
const currentWorkflowId = `${isLive ? 'live' : 'draft'}-${workflow.lastUpdatedAt}`;
|
||||||
|
|
||||||
|
// Special case: if we're switching to draft mode and the workflow data looks like live data
|
||||||
|
// (same lastUpdatedAt as the previous live data), don't reset the state yet
|
||||||
|
if (!isLive && lastWorkflowId && lastWorkflowId.startsWith('live-') &&
|
||||||
|
currentWorkflowId === `draft-${workflow.lastUpdatedAt}`) {
|
||||||
|
// This is likely stale draft data that matches live data
|
||||||
|
// Don't reset the state, just update the ID
|
||||||
|
setLastWorkflowId(currentWorkflowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastWorkflowId !== currentWorkflowId) {
|
||||||
|
dispatch({ type: "restore_state", state: { ...state.present, workflow } });
|
||||||
|
setLastWorkflowId(currentWorkflowId);
|
||||||
|
}
|
||||||
|
}, [workflow, isLive, lastWorkflowId, state.present]);
|
||||||
|
|
||||||
|
// Handle the case where we switch to draft mode but get stale data
|
||||||
|
useEffect(() => {
|
||||||
|
// If we're in draft mode but the workflow data looks like live data (same lastUpdatedAt as live)
|
||||||
|
// and we just switched from live mode, we need to wait for fresh draft data
|
||||||
|
if (!isLive && lastWorkflowId && lastWorkflowId.startsWith('live-')) {
|
||||||
|
// We just switched from live to draft, but we might have stale data
|
||||||
|
// Clear the selection to prevent showing wrong data
|
||||||
|
dispatch({ type: "unselect_agent" });
|
||||||
|
}
|
||||||
|
}, [isLive, lastWorkflowId]);
|
||||||
|
|
||||||
|
// Additional effect to handle mode changes that might not trigger workflow prop updates
|
||||||
|
useEffect(() => {
|
||||||
|
// If we're in draft mode but the workflow state contains live data, clear selection
|
||||||
|
// This prevents showing wrong data while waiting for the correct workflow prop
|
||||||
|
if (!isLive && state.present.isLive) {
|
||||||
|
dispatch({ type: "unselect_agent" });
|
||||||
|
}
|
||||||
|
}, [isLive, state.present.isLive]);
|
||||||
|
|
||||||
|
function handleTogglePanel() {
|
||||||
|
if (isLive && activePanel === 'playground') {
|
||||||
|
// User is trying to switch to Build mode in live mode
|
||||||
|
handleModeTransition('draft', 'switch_draft');
|
||||||
|
setShowBuildModeBanner(true);
|
||||||
|
// Auto-hide banner after 5 seconds
|
||||||
|
setTimeout(() => setShowBuildModeBanner(false), 5000);
|
||||||
|
} else {
|
||||||
|
setActivePanel(activePanel === 'playground' ? 'copilot' : 'playground');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleLeftPanel() {
|
||||||
|
setIsLeftPanelCollapsed(!isLeftPanelCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
const validateProjectName = (value: string) => {
|
const validateProjectName = (value: string) => {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
setProjectNameError("Project name cannot be empty");
|
setProjectNameError("Project name cannot be empty");
|
||||||
|
|
@ -1386,6 +1592,39 @@ export function WorkflowEditor({
|
||||||
onSelectPrompt: handleSelectPrompt,
|
onSelectPrompt: handleSelectPrompt,
|
||||||
}}>
|
}}>
|
||||||
<div className="h-full flex flex-col gap-5">
|
<div className="h-full flex flex-col gap-5">
|
||||||
|
{/* Live Workflow Edit Modal */}
|
||||||
|
<Modal isOpen={showEditModal} onClose={handleCancelEdit} size="md">
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
|
<span>Edit Live Workflow</span>
|
||||||
|
</div>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Seems like you're trying to edit the live workflow. Only the draft version can be modified. Changes will not be saved.
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onPress={handleCancelEdit}
|
||||||
|
className="text-gray-600"
|
||||||
|
>
|
||||||
|
View the live version
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={handleSwitchToDraft}
|
||||||
|
className="bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
Switch to draft
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Top Bar - Isolated like sidebar */}
|
{/* Top Bar - Isolated like sidebar */}
|
||||||
<TopBar
|
<TopBar
|
||||||
localProjectName={localProjectName}
|
localProjectName={localProjectName}
|
||||||
|
|
@ -1395,21 +1634,42 @@ export function WorkflowEditor({
|
||||||
publishing={state.present.publishing}
|
publishing={state.present.publishing}
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
showCopySuccess={showCopySuccess}
|
showCopySuccess={showCopySuccess}
|
||||||
|
showBuildModeBanner={showBuildModeBanner}
|
||||||
canUndo={state.currentIndex > 0}
|
canUndo={state.currentIndex > 0}
|
||||||
canRedo={state.currentIndex < state.patches.length}
|
canRedo={state.currentIndex < state.patches.length}
|
||||||
showCopilot={showCopilot}
|
activePanel={activePanel}
|
||||||
onUndo={() => dispatch({ type: "undo" })}
|
hasAgentInstructionChanges={hasAgentInstructionChanges}
|
||||||
onRedo={() => dispatch({ type: "redo" })}
|
hasPlaygroundTested={hasPlaygroundTested}
|
||||||
|
hasPublished={hasPublished}
|
||||||
|
hasClickedUse={hasClickedUse}
|
||||||
|
onUndo={() => dispatchGuarded({ type: "undo" })}
|
||||||
|
onRedo={() => dispatchGuarded({ type: "redo" })}
|
||||||
onDownloadJSON={handleDownloadJSON}
|
onDownloadJSON={handleDownloadJSON}
|
||||||
onPublishWorkflow={handlePublishWorkflow}
|
onPublishWorkflow={handlePublishWorkflow}
|
||||||
onChangeMode={onChangeMode}
|
onChangeMode={onChangeMode}
|
||||||
onRevertToLive={handleRevertToLive}
|
onRevertToLive={handleRevertToLive}
|
||||||
onToggleCopilot={() => setShowCopilot(!showCopilot)}
|
onTogglePanel={handleTogglePanel}
|
||||||
|
onUseAssistantClick={markUseAssistantClicked}
|
||||||
|
onStartNewChatAndFocus={handleStartNewChatAndFocus}
|
||||||
|
onStartBuildTour={() => setShowBuildTour(true)}
|
||||||
|
onStartTestTour={() => setShowTestTour(true)}
|
||||||
|
onStartPublishTour={() => {
|
||||||
|
if (isLive) {
|
||||||
|
handleModeTransition('draft', 'switch_draft');
|
||||||
|
}
|
||||||
|
setShowPublishTour(true);
|
||||||
|
}}
|
||||||
|
onStartUseTour={() => setShowUseTour(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<ResizablePanelGroup direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
|
<ResizablePanelGroup direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
|
||||||
<ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}>
|
<ResizablePanel
|
||||||
|
key={`entity-list-${state.present.selection ? '3-pane' : '2-pane'}`}
|
||||||
|
minSize={10}
|
||||||
|
defaultSize={PANEL_RATIOS.entityList}
|
||||||
|
className={isLeftPanelCollapsed ? 'hidden' : ''}
|
||||||
|
>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<EntityList
|
<EntityList
|
||||||
ref={entityListRef}
|
ref={entityListRef}
|
||||||
|
|
@ -1462,24 +1722,16 @@ export function WorkflowEditor({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
|
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed || !state.present.selection) ? 'hidden' : ''}`} />
|
||||||
|
|
||||||
|
{/* Config Panel - always rendered, visibility controlled */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
minSize={20}
|
minSize={20}
|
||||||
defaultSize={showCopilot ? PANEL_RATIOS.chatApp : PANEL_RATIOS.chatApp + PANEL_RATIOS.copilot}
|
defaultSize={45}
|
||||||
className="overflow-auto"
|
className={`overflow-auto ${!state.present.selection ? 'hidden' : ''}`}
|
||||||
>
|
>
|
||||||
<ChatApp
|
|
||||||
key={'' + state.present.chatKey}
|
|
||||||
hidden={state.present.selection !== null}
|
|
||||||
projectId={projectId}
|
|
||||||
workflow={state.present.workflow}
|
|
||||||
messageSubscriber={updateChatMessages}
|
|
||||||
onPanelClick={handlePlaygroundClick}
|
|
||||||
triggerCopilotChat={triggerCopilotChat}
|
|
||||||
isLiveWorkflow={isLive}
|
|
||||||
/>
|
|
||||||
{state.present.selection?.type === "agent" && <AgentConfig
|
{state.present.selection?.type === "agent" && <AgentConfig
|
||||||
key={`agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}`}
|
key={`agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}-${configKey}`}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
workflow={state.present.workflow}
|
workflow={state.present.workflow}
|
||||||
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
|
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
|
||||||
|
|
@ -1489,7 +1741,7 @@ export function WorkflowEditor({
|
||||||
tools={state.present.workflow.tools}
|
tools={state.present.workflow.tools}
|
||||||
prompts={state.present.workflow.prompts}
|
prompts={state.present.workflow.prompts}
|
||||||
dataSources={dataSources}
|
dataSources={dataSources}
|
||||||
handleUpdate={handleUpdateAgent.bind(null, state.present.selection.name)}
|
handleUpdate={(update) => { dispatchGuarded({ type: "update_agent", name: state.present.selection!.name, agent: update }); }}
|
||||||
handleClose={handleUnselectAgent}
|
handleClose={handleUnselectAgent}
|
||||||
useRag={useRag}
|
useRag={useRag}
|
||||||
triggerCopilotChat={triggerCopilotChat}
|
triggerCopilotChat={triggerCopilotChat}
|
||||||
|
|
@ -1501,33 +1753,33 @@ export function WorkflowEditor({
|
||||||
(tool) => tool.name === state.present.selection!.name
|
(tool) => tool.name === state.present.selection!.name
|
||||||
);
|
);
|
||||||
return <ToolConfig
|
return <ToolConfig
|
||||||
key={state.present.selection.name}
|
key={`${state.present.selection.name}-${configKey}`}
|
||||||
tool={selectedTool!}
|
tool={selectedTool!}
|
||||||
usedToolNames={new Set([
|
usedToolNames={new Set([
|
||||||
...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name),
|
...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name),
|
||||||
])}
|
])}
|
||||||
handleUpdate={handleUpdateTool.bind(null, state.present.selection.name)}
|
handleUpdate={(update) => { dispatchGuarded({ type: "update_tool", name: state.present.selection!.name, tool: update }); }}
|
||||||
handleClose={handleUnselectTool}
|
handleClose={handleUnselectTool}
|
||||||
/>;
|
/>;
|
||||||
})()}
|
})()}
|
||||||
{state.present.selection?.type === "prompt" && <PromptConfig
|
{state.present.selection?.type === "prompt" && <PromptConfig
|
||||||
key={state.present.selection.name}
|
key={`${state.present.selection.name}-${configKey}`}
|
||||||
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
|
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
|
||||||
agents={state.present.workflow.agents}
|
agents={state.present.workflow.agents}
|
||||||
tools={state.present.workflow.tools}
|
tools={state.present.workflow.tools}
|
||||||
prompts={state.present.workflow.prompts}
|
prompts={state.present.workflow.prompts}
|
||||||
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
|
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
|
||||||
handleUpdate={handleUpdatePrompt.bind(null, state.present.selection.name)}
|
handleUpdate={(update) => { dispatchGuarded({ type: "update_prompt", name: state.present.selection!.name, prompt: update }); }}
|
||||||
handleClose={handleUnselectPrompt}
|
handleClose={handleUnselectPrompt}
|
||||||
/>}
|
/>}
|
||||||
{state.present.selection?.type === "datasource" && <DataSourceConfig
|
{state.present.selection?.type === "datasource" && <DataSourceConfig
|
||||||
key={state.present.selection.name}
|
key={`${state.present.selection.name}-${configKey}`}
|
||||||
dataSourceId={state.present.selection.name}
|
dataSourceId={state.present.selection.name}
|
||||||
handleClose={() => dispatch({ type: "unselect_datasource" })}
|
handleClose={() => dispatch({ type: "unselect_datasource" })}
|
||||||
onDataSourceUpdate={onDataSourcesUpdated}
|
onDataSourceUpdate={onDataSourcesUpdated}
|
||||||
/>}
|
/>}
|
||||||
{state.present.selection?.type === "pipeline" && <PipelineConfig
|
{state.present.selection?.type === "pipeline" && <PipelineConfig
|
||||||
key={state.present.selection.name}
|
key={`${state.present.selection.name}-${configKey}`}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
workflow={state.present.workflow}
|
workflow={state.present.workflow}
|
||||||
pipeline={state.present.workflow.pipelines?.find((pipeline) => pipeline.name === state.present.selection!.name)!}
|
pipeline={state.present.workflow.pipelines?.find((pipeline) => pipeline.name === state.present.selection!.name)!}
|
||||||
|
|
@ -1563,38 +1815,56 @@ export function WorkflowEditor({
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
{showCopilot && (
|
{/* Second handle - between config and chat panels */}
|
||||||
<>
|
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed && !state.present.selection) ? 'hidden' : ''}`} />
|
||||||
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
|
|
||||||
<ResizablePanel
|
{/* ChatApp/Copilot Panel - always visible */}
|
||||||
minSize={10}
|
<ResizablePanel
|
||||||
defaultSize={PANEL_RATIOS.copilot}
|
key={`chat-panel-${state.present.selection ? '3-pane' : '2-pane'}`}
|
||||||
onResize={(size) => setCopilotWidth(size)}
|
minSize={20}
|
||||||
>
|
defaultSize={state.present.selection ? 30 : PANEL_RATIOS.chatApp + PANEL_RATIOS.copilot}
|
||||||
<Copilot
|
className="overflow-auto"
|
||||||
ref={copilotRef}
|
>
|
||||||
projectId={projectId}
|
<div className={(activePanel === 'playground') ? 'block h-full' : 'hidden h-full'}>
|
||||||
workflow={state.present.workflow}
|
<ChatApp
|
||||||
dispatch={dispatch}
|
key={'' + state.present.chatKey}
|
||||||
chatContext={
|
projectId={projectId}
|
||||||
state.present.selection &&
|
workflow={state.present.workflow}
|
||||||
(state.present.selection.type === "agent" ||
|
messageSubscriber={updateChatMessages}
|
||||||
state.present.selection.type === "tool" ||
|
onPanelClick={handlePlaygroundClick}
|
||||||
state.present.selection.type === "prompt")
|
triggerCopilotChat={triggerCopilotChat}
|
||||||
? {
|
isLiveWorkflow={isLive}
|
||||||
type: state.present.selection.type,
|
activePanel={activePanel}
|
||||||
name: state.present.selection.name
|
onTogglePanel={handleTogglePanel}
|
||||||
}
|
onMessageSent={markPlaygroundTested}
|
||||||
: chatMessages.length > 0
|
/>
|
||||||
? { type: 'chat', messages: chatMessages }
|
</div>
|
||||||
: undefined
|
<div className={(activePanel === 'copilot') ? 'block h-full' : 'hidden h-full'}>
|
||||||
}
|
<Copilot
|
||||||
isInitialState={isInitialState}
|
ref={copilotRef}
|
||||||
dataSources={dataSources}
|
projectId={projectId}
|
||||||
/>
|
workflow={state.present.workflow}
|
||||||
</ResizablePanel>
|
dispatch={dispatch}
|
||||||
</>
|
chatContext={
|
||||||
)}
|
state.present.selection &&
|
||||||
|
(state.present.selection.type === "agent" ||
|
||||||
|
state.present.selection.type === "tool" ||
|
||||||
|
state.present.selection.type === "prompt")
|
||||||
|
? {
|
||||||
|
type: state.present.selection.type,
|
||||||
|
name: state.present.selection.name
|
||||||
|
}
|
||||||
|
: chatMessages.length > 0
|
||||||
|
? { type: 'chat', messages: chatMessages }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isInitialState={isInitialState}
|
||||||
|
dataSources={dataSources}
|
||||||
|
activePanel={activePanel}
|
||||||
|
onTogglePanel={handleTogglePanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
{USE_PRODUCT_TOUR && showTour && (
|
{USE_PRODUCT_TOUR && showTour && (
|
||||||
<ProductTour
|
<ProductTour
|
||||||
|
|
@ -1602,6 +1872,65 @@ export function WorkflowEditor({
|
||||||
onComplete={() => setShowTour(false)}
|
onComplete={() => setShowTour(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showBuildTour && (
|
||||||
|
<ProductTour
|
||||||
|
projectId={projectId}
|
||||||
|
forceStart
|
||||||
|
stepsOverride={[
|
||||||
|
{ target: 'copilot', title: 'Step 1/5', content: 'Use Copilot to create and refine agents. Describe what you need, then iterate with its suggestions.' },
|
||||||
|
{ target: 'entity-agents', title: 'Step 2/5', content: 'All your agents appear here. Adjust instructions, switch models, and fine-tune their behavior.' },
|
||||||
|
{ target: 'entity-tools', title: 'Step 3/5', content: 'Pick from thousands of ready-made tools or connect your own MCP servers.' },
|
||||||
|
{ target: 'entity-data', title: 'Step 4/5', content: 'Upload files, scrape websites, or add free-text knowledge to guide your agents.' },
|
||||||
|
{ target: 'entity-prompts', title: 'Step 5/5', content: 'Define reusable context variables automatically shared across all agents.' },
|
||||||
|
]}
|
||||||
|
onStepChange={(_, step) => {
|
||||||
|
if (step.target === 'copilot') setActivePanel('copilot');
|
||||||
|
}}
|
||||||
|
onComplete={() => setShowBuildTour(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showTestTour && (
|
||||||
|
<ProductTour
|
||||||
|
projectId={projectId}
|
||||||
|
forceStart
|
||||||
|
stepsOverride={[
|
||||||
|
{ target: 'playground', title: 'Step 1/2', content: 'Chat with your assistant to test it. Send messages, watch tool calls in action, and debug agent flows.' },
|
||||||
|
{ target: 'copilot', title: 'Step 2/2', content: 'Ask Copilot to improve your agents based on test results. Use “Fix” and “Explain” to iterate quickly.' },
|
||||||
|
]}
|
||||||
|
onStepChange={(index) => {
|
||||||
|
if (index === 0) setActivePanel('playground');
|
||||||
|
if (index === 1) setActivePanel('copilot');
|
||||||
|
}}
|
||||||
|
onComplete={() => setShowTestTour(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showUseTour && (
|
||||||
|
<ProductTour
|
||||||
|
projectId={projectId}
|
||||||
|
forceStart
|
||||||
|
stepsOverride={[
|
||||||
|
{ target: 'playground', title: 'Step 1/5', content: 'Chat: you can chat with your assistant here.' },
|
||||||
|
{ target: 'triggers', title: 'Step 2/5', content: 'Triggers: set up external (webhook/integration) or time-based schedules.' },
|
||||||
|
{ target: 'jobs', title: 'Step 3/5', content: 'Jobs: monitor your trigger runs and scheduled tasks here.' },
|
||||||
|
{ target: 'settings', title: 'Step 4/5', content: 'Settings: find API keys to connect with the API and SDK.' },
|
||||||
|
{ target: 'conversations', title: 'Step 5/5', content: 'Conversations: see all past interactions in one place, including manual chats, trigger activity, and API calls.' },
|
||||||
|
]}
|
||||||
|
onStepChange={(index) => {
|
||||||
|
if (index === 0) setActivePanel('playground');
|
||||||
|
}}
|
||||||
|
onComplete={() => setShowUseTour(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showPublishTour && (
|
||||||
|
<ProductTour
|
||||||
|
projectId={projectId}
|
||||||
|
forceStart
|
||||||
|
stepsOverride={[
|
||||||
|
{ target: 'deploy', title: 'Publish', content: 'Click Publish to make your workflow live, enabling triggers and API/SDK access. You can revert to a draft at any time.' },
|
||||||
|
]}
|
||||||
|
onComplete={() => setShowPublishTour(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Revert to Live Confirmation Modal */}
|
{/* Revert to Live Confirmation Modal */}
|
||||||
<Modal isOpen={isRevertModalOpen} onClose={onRevertModalClose}>
|
<Modal isOpen={isRevertModalOpen} onClose={onRevertModalClose}>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS === 'tru
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 6;
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
const copilotPrompts = {
|
const copilotPrompts = {
|
||||||
"Blog assistant": {
|
"Blog assistant": {
|
||||||
|
|
@ -363,7 +363,7 @@ export function BuildAssistantSection() {
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab key="existing" title="My Assistants">
|
<Tab key="existing" title="My Assistants">
|
||||||
<div className="pt-4">
|
<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">
|
<div className="flex flex-col bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
{projectsLoading ? (
|
{projectsLoading ? (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
|
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
|
||||||
Loading assistants...
|
Loading assistants...
|
||||||
|
|
@ -374,7 +374,7 @@ export function BuildAssistantSection() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{currentProjects.map((project) => (
|
{currentProjects.map((project) => (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,19 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
||||||
: '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'
|
: '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'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
|
data-tour-target={
|
||||||
|
item.href === 'config'
|
||||||
|
? 'settings'
|
||||||
|
: item.href === 'sources'
|
||||||
|
? 'entity-data-sources'
|
||||||
|
: item.href === 'manage-triggers'
|
||||||
|
? 'triggers'
|
||||||
|
: item.href === 'jobs'
|
||||||
|
? 'jobs'
|
||||||
|
: item.href === 'conversations'
|
||||||
|
? 'conversations'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={COLLAPSED_ICON_SIZE}
|
size={COLLAPSED_ICON_SIZE}
|
||||||
|
|
@ -218,7 +230,19 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
||||||
: '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'
|
: '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'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
|
data-tour-target={
|
||||||
|
item.href === 'config'
|
||||||
|
? 'settings'
|
||||||
|
: item.href === 'sources'
|
||||||
|
? 'entity-data-sources'
|
||||||
|
: item.href === 'manage-triggers'
|
||||||
|
? 'triggers'
|
||||||
|
: item.href === 'jobs'
|
||||||
|
? 'jobs'
|
||||||
|
: item.href === 'conversations'
|
||||||
|
? 'conversations'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={EXPANDED_ICON_SIZE}
|
size={EXPANDED_ICON_SIZE}
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,11 @@ export function ComposeBoxCopilot({
|
||||||
group-hover:opacity-100 transition-opacity">
|
group-hover:opacity-100 transition-opacity">
|
||||||
Press ⌘ + Enter to send
|
Press ⌘ + Enter to send
|
||||||
</div>
|
</div>
|
||||||
{/* Outer container with padding */}
|
{/* Outer container without external padding; textarea grows to fill */}
|
||||||
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] p-3 relative
|
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] relative
|
||||||
bg-white dark:bg-[#1e2023] flex items-end gap-2">
|
bg-white dark:bg-[#1e2023] flex items-end gap-2">
|
||||||
{/* Textarea */}
|
{/* Textarea */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 p-3">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,15 @@ export function Panel({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"shrink-0 border-b relative",
|
// For copilot and playground, mimic TopBar appearance
|
||||||
variant === 'copilot' ? "border-zinc-300 dark:border-zinc-700" : "border-zinc-100 dark:border-zinc-800",
|
(variant === 'copilot' || variant === 'playground')
|
||||||
|
? "shrink-0 relative rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm border border-zinc-200 dark:border-zinc-800 px-0 pt-0 pb-2 mx-0 mt-0 mb-2 flex items-center justify-between"
|
||||||
|
: "shrink-0 border-b relative",
|
||||||
|
(variant !== 'copilot' && variant !== 'playground') && "border-zinc-100 dark:border-zinc-800",
|
||||||
{
|
{
|
||||||
"flex flex-col gap-3 px-4 py-3": variant === 'projects',
|
"flex flex-col gap-3 px-4 py-3": variant === 'projects',
|
||||||
"flex items-center justify-between h-[53px] p-3": isEntityList,
|
"flex items-center justify-between h-[53px] p-3": isEntityList,
|
||||||
"flex items-center justify-between px-6 py-3": !isEntityList && variant !== 'projects'
|
"flex items-center justify-between px-6 py-3": !isEntityList && variant !== 'projects' && variant !== 'copilot' && variant !== 'playground'
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -102,39 +105,27 @@ export function Panel({
|
||||||
</div>}
|
</div>}
|
||||||
</>
|
</>
|
||||||
) : variant === 'copilot' ? (
|
) : variant === 'copilot' ? (
|
||||||
<>
|
<div className="w-full flex items-center justify-between px-3 pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon && icon}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
|
||||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{rightActions}
|
{rightActions}
|
||||||
</>
|
</div>
|
||||||
) : variant === 'playground' ? (
|
) : variant === 'playground' ? (
|
||||||
<>
|
<div className="w-full flex items-center justify-between px-3 pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon && icon}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
|
||||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{rightActions}
|
{rightActions}
|
||||||
</>
|
</div>
|
||||||
) : isEntityList ? (
|
) : isEntityList ? (
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
{title}
|
{title}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
"use client";
|
||||||
import { useFloating, offset, flip, shift, arrow, FloatingArrow, FloatingPortal, autoUpdate } from '@floating-ui/react';
|
import { useFloating, offset, flip, shift, arrow, FloatingArrow, FloatingPortal, autoUpdate } from '@floating-ui/react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { XIcon } from 'lucide-react';
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface TourStep {
|
export interface TourStep {
|
||||||
target: string;
|
target: string;
|
||||||
content: string;
|
content: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -59,7 +60,7 @@ const TOUR_STEPS: TourStep[] = [
|
||||||
function TourBackdrop({ targetElement }: { targetElement: Element | null }) {
|
function TourBackdrop({ targetElement }: { targetElement: Element | null }) {
|
||||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||||
const isPanelTarget = targetElement?.getAttribute('data-tour-target') &&
|
const isPanelTarget = targetElement?.getAttribute('data-tour-target') &&
|
||||||
['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground'].includes(
|
['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground', 'settings', 'triggers', 'jobs', 'conversations'].includes(
|
||||||
targetElement.getAttribute('data-tour-target')!
|
targetElement.getAttribute('data-tour-target')!
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -136,28 +137,36 @@ function TourBackdrop({ targetElement }: { targetElement: Element | null }) {
|
||||||
|
|
||||||
export function ProductTour({
|
export function ProductTour({
|
||||||
projectId,
|
projectId,
|
||||||
onComplete
|
onComplete,
|
||||||
|
stepsOverride,
|
||||||
|
forceStart = false,
|
||||||
|
onStepChange,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
|
stepsOverride?: TourStep[];
|
||||||
|
forceStart?: boolean;
|
||||||
|
onStepChange?: (index: number, step: TourStep) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const steps = stepsOverride && stepsOverride.length > 0 ? stepsOverride : TOUR_STEPS;
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [shouldShow, setShouldShow] = useState(true);
|
const [shouldShow, setShouldShow] = useState(true);
|
||||||
const arrowRef = useRef(null);
|
const arrowRef = useRef(null);
|
||||||
|
|
||||||
// Check if tour has been completed by the user
|
// Check if tour has been completed by the user, unless forced
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (forceStart) return;
|
||||||
const tourCompleted = localStorage.getItem('user_product_tour_completed');
|
const tourCompleted = localStorage.getItem('user_product_tour_completed');
|
||||||
if (tourCompleted) {
|
if (tourCompleted) {
|
||||||
setShouldShow(false);
|
setShouldShow(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [forceStart]);
|
||||||
|
|
||||||
const currentTarget = TOUR_STEPS[currentStep].target;
|
const currentTarget = steps[currentStep].target;
|
||||||
const targetElement = document.querySelector(`[data-tour-target="${currentTarget}"]`);
|
const [targetElement, setTargetElement] = useState<Element | null>(null);
|
||||||
|
|
||||||
// Determine if the target is a panel that should have the hint on the side
|
// Determine if the target is a panel that should have the hint on the side
|
||||||
const isPanelTarget = ['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground'].includes(currentTarget);
|
const isPanelTarget = ['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground', 'entity-data', 'settings', 'triggers', 'jobs', 'conversations'].includes(currentTarget);
|
||||||
|
|
||||||
const { x, y, strategy, refs, context, middlewareData } = useFloating({
|
const { x, y, strategy, refs, context, middlewareData } = useFloating({
|
||||||
placement: isPanelTarget ? 'right' : 'top',
|
placement: isPanelTarget ? 'right' : 'top',
|
||||||
|
|
@ -177,15 +186,33 @@ export function ProductTour({
|
||||||
whileElementsMounted: autoUpdate
|
whileElementsMounted: autoUpdate
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update reference element when step changes
|
// Update reference element when step changes and notify parent first, then resolve target element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetElement) {
|
let raf1: number | undefined;
|
||||||
refs.setReference(targetElement);
|
let raf2: number | undefined;
|
||||||
|
|
||||||
|
if (onStepChange) {
|
||||||
|
onStepChange(currentStep, steps[currentStep]);
|
||||||
}
|
}
|
||||||
}, [currentStep, targetElement, refs]);
|
|
||||||
|
// Give the parent a frame to update DOM (e.g., switching panels), then query element
|
||||||
|
raf1 = requestAnimationFrame(() => {
|
||||||
|
raf2 = requestAnimationFrame(() => {
|
||||||
|
const el = document.querySelector(`[data-tour-target="${currentTarget}"]`);
|
||||||
|
setTargetElement(el);
|
||||||
|
if (el) refs.setReference(el as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (raf1) cancelAnimationFrame(raf1);
|
||||||
|
if (raf2) cancelAnimationFrame(raf2);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentStep, currentTarget]);
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
if (currentStep < TOUR_STEPS.length - 1) {
|
if (currentStep < steps.length - 1) {
|
||||||
setCurrentStep(prev => prev + 1);
|
setCurrentStep(prev => prev + 1);
|
||||||
} else {
|
} else {
|
||||||
// Mark tour as completed for the user
|
// Mark tour as completed for the user
|
||||||
|
|
@ -195,7 +222,7 @@ export function ProductTour({
|
||||||
setShouldShow(false);
|
setShouldShow(false);
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
}, [currentStep, projectId, onComplete]);
|
}, [currentStep, projectId, onComplete, steps.length]);
|
||||||
|
|
||||||
const handleSkip = useCallback(() => {
|
const handleSkip = useCallback(() => {
|
||||||
// Mark tour as completed for the user
|
// Mark tour as completed for the user
|
||||||
|
|
@ -235,10 +262,10 @@ export function ProductTour({
|
||||||
<XIcon size={16} />
|
<XIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
{TOUR_STEPS[currentStep].title}
|
{steps[currentStep].title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3 whitespace-pre-line [&>a]:underline"
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3 whitespace-pre-line [&>a]:underline"
|
||||||
dangerouslySetInnerHTML={{ __html: TOUR_STEPS[currentStep].content }}
|
dangerouslySetInnerHTML={{ __html: steps[currentStep].content }}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function ToolParamCard({
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex-1 text-left truncate">{param.name}</span>
|
<span className="text-sm font-normal text-gray-900 dark:text-gray-100 flex-1 text-left truncate">{param.name}</span>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<Button
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
|
|
||||||
114
apps/rowboat/components/ui/progress-bar.tsx
Normal file
114
apps/rowboat/components/ui/progress-bar.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"use client";
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Tooltip } from "@heroui/react";
|
||||||
|
|
||||||
|
export interface ProgressStep {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
completed: boolean;
|
||||||
|
icon?: string; // The icon/symbol to show instead of number
|
||||||
|
isCurrent?: boolean; // Whether this is the current step
|
||||||
|
shortLabel?: string; // Optional short label to show inline on larger screens
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
steps: ProgressStep[];
|
||||||
|
className?: string;
|
||||||
|
onStepClick?: (step: ProgressStep, index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ steps, className, onStepClick }: ProgressBarProps) {
|
||||||
|
const getShortLabel = (label: string) => {
|
||||||
|
if (!label) return "";
|
||||||
|
const beforeColon = label.split(":")[0]?.trim();
|
||||||
|
if (beforeColon) return beforeColon;
|
||||||
|
const firstWord = label.split(" ")[0]?.trim();
|
||||||
|
return firstWord || label;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Workflow progress" className={cn("flex items-center gap-4", className)}>
|
||||||
|
{/* Progress Label */}
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-2">
|
||||||
|
Progress:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<ol role="list" className="flex items-center gap-2">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isLast = index === steps.length - 1;
|
||||||
|
const tooltipText = (() => {
|
||||||
|
switch (step.id) {
|
||||||
|
case 1:
|
||||||
|
return 'Build your assistant - click for tour';
|
||||||
|
case 2:
|
||||||
|
return 'Test your assistant - click for tour';
|
||||||
|
case 3:
|
||||||
|
return 'Make assistant live - click for tour';
|
||||||
|
case 4:
|
||||||
|
return 'Interact with your assistant - click for tour';
|
||||||
|
default:
|
||||||
|
return 'Click for tour';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={step.id} className="flex items-center">
|
||||||
|
{/* Step Circle with Tooltip */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<Tooltip
|
||||||
|
content={tooltipText}
|
||||||
|
size="lg"
|
||||||
|
delay={100}
|
||||||
|
placement="bottom"
|
||||||
|
classNames={{ content: "text-base" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${step.completed ? "Completed" : step.isCurrent ? "Current" : "Pending"} step ${step.id}: ${step.label}`}
|
||||||
|
aria-current={step.isCurrent ? "step" : undefined}
|
||||||
|
role={onStepClick ? 'button' as const : undefined}
|
||||||
|
onClick={onStepClick ? () => onStepClick(step, index) : undefined}
|
||||||
|
onKeyDown={onStepClick ? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onStepClick(step, index);
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
className={cn(
|
||||||
|
"w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-semibold transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400",
|
||||||
|
step.completed
|
||||||
|
? "bg-green-500 border-green-500 text-white"
|
||||||
|
: step.isCurrent
|
||||||
|
? "bg-yellow-500 border-yellow-500 text-white ring-2 ring-yellow-300/60 shadow-sm"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400"
|
||||||
|
, onStepClick ? "cursor-pointer hover:scale-105" : "cursor-default")}
|
||||||
|
>
|
||||||
|
{step.completed ? "✓" : step.isCurrent ? "⚡" : "○"}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="hidden md:block mt-1 text-[11px] leading-none text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{step.shortLabel ?? getShortLabel(step.label)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connecting Line */}
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"h-0.5 w-8 mx-2 transition-all duration-300 motion-reduce:transition-none",
|
||||||
|
step.completed
|
||||||
|
? "bg-green-500"
|
||||||
|
: "border-t-2 border-dashed border-gray-300 dark:border-gray-600"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -903,7 +903,7 @@ async function* handleNativeHandoffEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular handoff handling (non-pipeline)
|
// Regular handoff handling (non-pipeline)
|
||||||
const maxCalls = targetAgentConfig?.maxCallsPerParentAgent || 3;
|
const maxCalls = targetAgentConfig?.maxCallsPerParentAgent || 1;
|
||||||
const currentCalls = transferCounter.get(agentName, targetAgentName);
|
const currentCalls = transferCounter.get(agentName, targetAgentName);
|
||||||
|
|
||||||
if (targetAgentConfig?.outputVisibility === 'internal' && currentCalls >= maxCalls) {
|
if (targetAgentConfig?.outputVisibility === 'internal' && currentCalls >= maxCalls) {
|
||||||
|
|
@ -955,7 +955,7 @@ async function* handleHandoffEvent(
|
||||||
// Only apply max calls limit to internal agents (task agents)
|
// Only apply max calls limit to internal agents (task agents)
|
||||||
const targetAgentConfig = agentConfig[event.item.targetAgent.name];
|
const targetAgentConfig = agentConfig[event.item.targetAgent.name];
|
||||||
if (targetAgentConfig?.outputVisibility === 'internal') {
|
if (targetAgentConfig?.outputVisibility === 'internal') {
|
||||||
const maxCalls = targetAgentConfig?.maxCallsPerParentAgent || 3;
|
const maxCalls = targetAgentConfig?.maxCallsPerParentAgent || 1;
|
||||||
const currentCalls = transferCounter.get(agentName, event.item.targetAgent.name);
|
const currentCalls = transferCounter.get(agentName, event.item.targetAgent.name);
|
||||||
if (currentCalls >= maxCalls) {
|
if (currentCalls >= maxCalls) {
|
||||||
eventLogger.log(`⚠️ SKIPPING: handoff to ${event.item.targetAgent.name} - max calls ${maxCalls} exceeded from ${agentName}`);
|
eventLogger.log(`⚠️ SKIPPING: handoff to ${event.item.targetAgent.name} - max calls ${maxCalls} exceeded from ${agentName}`);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ export const composio = new Composio({
|
||||||
apiKey: COMPOSIO_API_KEY,
|
apiKey: COMPOSIO_API_KEY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Warn if API key is missing, helps diagnose HTML error pages from auth proxies
|
||||||
|
if (!process.env.COMPOSIO_API_KEY || COMPOSIO_API_KEY === 'test') {
|
||||||
|
const warnLogger = new PrefixLogger('composioApiCall');
|
||||||
|
warnLogger.log('WARNING: COMPOSIO_API_KEY is not set or using default placeholder. Requests may fail with non-JSON HTML error pages.');
|
||||||
|
}
|
||||||
|
|
||||||
export async function composioApiCall<T extends z.ZodTypeAny>(
|
export async function composioApiCall<T extends z.ZodTypeAny>(
|
||||||
schema: T,
|
schema: T,
|
||||||
url: string,
|
url: string,
|
||||||
|
|
@ -32,11 +38,36 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
|
||||||
});
|
});
|
||||||
const duration = Date.now() - then;
|
const duration = Date.now() - then;
|
||||||
logger.log(`Took: ${duration}ms`);
|
logger.log(`Took: ${duration}ms`);
|
||||||
const data = await response.json();
|
|
||||||
if ('error' in data) {
|
const contentType = response.headers.get('content-type') || '';
|
||||||
const response = ZErrorResponse.parse(data);
|
const rawText = await response.text();
|
||||||
throw new Error(`(code: ${response.error.error_code}): ${response.error.message}: ${response.error.suggested_fix}: ${response.error.errors?.join(', ')}`);
|
|
||||||
|
// Helpful logging when non-OK or non-JSON
|
||||||
|
if (!response.ok || !contentType.includes('application/json')) {
|
||||||
|
logger.log(`Non-JSON or non-OK response`, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
contentType,
|
||||||
|
preview: rawText.slice(0, 200),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Composio API error: ${response.status} ${response.statusText} (url: ${url}) body: ${rawText.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = contentType.includes('application/json') ? JSON.parse(rawText) : (() => { throw new Error('Expected JSON but received non-JSON response'); })();
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(`Failed to parse Composio JSON response (url: ${url}): ${e?.message || e}. Body preview: ${rawText.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'object' && data !== null && 'error' in (data as any)) {
|
||||||
|
const parsedError = ZErrorResponse.parse(data);
|
||||||
|
throw new Error(`(code: ${parsedError.error.error_code}): ${parsedError.error.message}: ${parsedError.error.suggested_fix}: ${parsedError.error.errors?.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
return schema.parse(data);
|
return schema.parse(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log(`Error:`, error);
|
logger.log(`Error:`, error);
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,23 @@ export class SyncConnectedAccountUseCase implements ISyncConnectedAccountUseCase
|
||||||
}
|
}
|
||||||
const account = project.composioConnectedAccounts?.[toolkitSlug];
|
const account = project.composioConnectedAccounts?.[toolkitSlug];
|
||||||
if (!account || account.id !== connectedAccountId) {
|
if (!account || account.id !== connectedAccountId) {
|
||||||
throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId}`);
|
// Log detailed mismatch context to aid debugging
|
||||||
|
try {
|
||||||
|
// Avoid crashing on logging itself
|
||||||
|
// Include both expected and stored IDs, toolkit slug, and available toolkits
|
||||||
|
// so we can quickly spot wrong slug or race conditions.
|
||||||
|
// Note: This is server-side logging only.
|
||||||
|
console.error('[Composio] Connected account mismatch', {
|
||||||
|
projectId,
|
||||||
|
toolkitSlug,
|
||||||
|
expectedConnectedAccountId: connectedAccountId,
|
||||||
|
storedAccountId: account?.id ?? null,
|
||||||
|
storedStatus: account?.status ?? null,
|
||||||
|
availableToolkits: Object.keys(project.composioConnectedAccounts || {}),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId} (toolkit: ${toolkitSlug})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.status === 'ACTIVE') {
|
if (account.status === 'ACTIVE') {
|
||||||
|
|
@ -86,4 +102,3 @@ export class SyncConnectedAccountUseCase implements ISyncConnectedAccountUseCase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue