Merge pull request #234 from rowboatlabs/dev

Dev
This commit is contained in:
arkml 2025-09-08 17:26:02 +05:30 committed by GitHub
commit 1ade0a8df6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1163 additions and 379 deletions

View file

@ -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">

View file

@ -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>
); );

View file

@ -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>

View file

@ -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>
} }
> >

View file

@ -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

View file

@ -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>

View file

@ -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>
} }
> >

View file

@ -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>

View file

@ -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">

View file

@ -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() {

View file

@ -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>

View file

@ -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>

View file

@ -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&apos;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}>

View file

@ -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

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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

View file

@ -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"

View 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>
);
}

View file

@ -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}`);

View file

@ -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);

View file

@ -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
} }
} }