diff --git a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx index b3704a15..cfd74b5f 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx @@ -269,7 +269,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message toolQuery={toolQuery} /> -
+
{responseError && (

{responseError}

@@ -322,6 +322,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void dispatch: (action: WorkflowDispatch) => void; isInitialState?: boolean; dataSources?: z.infer[]; + activePanel: 'playground' | 'copilot'; + onTogglePanel: () => void; }>(({ projectId, workflow, @@ -329,6 +331,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void dispatch, isInitialState = false, dataSources, + activePanel, + onTogglePanel, }, ref) => { const [copilotKey, setCopilotKey] = useState(0); const [showCopySuccess, setShowCopySuccess] = useState(false); @@ -365,8 +369,34 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void } - title="Skipper" + title={ +
+
+ + +
+
+ } subtitle="Build your assistant" rightActions={
diff --git a/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx b/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx index dbc7685d..ed677073 100644 --- a/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx +++ b/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx @@ -364,56 +364,6 @@ export function ToolConfig({ Changes saved ✓
)} - {/* Tool Type Section */} - {!tool.isLibrary &&
-
-
- {tool.isMcp ? ( - - ) : tool.isComposio ? ( - - ) : ( - - )} -
-
-

- How this tool runs -

- {tool.isMcp &&
-

This tool is powered by the {tool.mcpServerName} MCP server.

-

- MCP (Model Context Protocol) tools are external services that provide additional capabilities to your agent. -

-
} - { tool.isComposio &&
-
-

This tool is powered by Composio

- {tool.composioData?.toolkitName && ( - - {tool.composioData.toolkitName} - - )} -
-

- Composio provides pre-built integrations with popular services and APIs. -

-
} - { tool.isWebhook &&
-
-

This tool is invoked using the webhook configured in project settings

-
-

- Webhook tools make HTTP requests to your configured endpoint when called by the agent. -

-
} - { !tool.isMcp && !tool.isComposio && !tool.isWebhook &&
-

This is a placeholder tool that should be mocked.

-
} -
-
-
} - {/* Identity Section */} } @@ -570,6 +520,47 @@ export function ToolConfig({ )}
+ + {/* Tool Type Section */} + {!tool.isLibrary &&
+
+
+ {tool.isMcp ? ( + + ) : tool.isComposio ? ( + + ) : ( + + )} +
+
+

+ How this tool runs +

+ {tool.isMcp &&
+

This tool is powered by the {tool.mcpServerName} MCP server.

+
} + { tool.isComposio &&
+
+

This tool is powered by Composio

+ {tool.composioData?.toolkitName && ( + + {tool.composioData.toolkitName} + + )} +
+
} + { tool.isWebhook &&
+
+

This tool is invoked using the webhook configured in project settings

+
+
} + { !tool.isMcp && !tool.isComposio && !tool.isWebhook &&
+

This is a placeholder tool that should be mocked.

+
} +
+
+
}
); diff --git a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/create-recurring-job-rule-form.tsx b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/create-recurring-job-rule-form.tsx index ccdd3dee..d6a12dee 100644 --- a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/create-recurring-job-rule-form.tsx +++ b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/create-recurring-job-rule-form.tsx @@ -25,7 +25,15 @@ const commonCronExamples = [ { 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 [loading, setLoading] = useState(false); const [messages, setMessages] = useState([ @@ -89,7 +97,11 @@ export function CreateRecurringJobRuleForm({ projectId }: { projectId: string }) input: { messages: convertedMessages }, cron: cronExpression, }); - router.push(`/projects/${projectId}/manage-triggers?tab=recurring`); + if (onBack) { + onBack(); + } else { + router.push(`/projects/${projectId}/manage-triggers?tab=recurring`); + } } catch (error) { console.error("Failed to create recurring job rule:", error); alert("Failed to create recurring job rule"); @@ -102,11 +114,23 @@ export function CreateRecurringJobRuleForm({ projectId }: { projectId: string }) - - - + ) : hasExistingTriggers ? ( + + + + ) : null}
CREATE RECURRING JOB RULE
diff --git a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rules-list.tsx b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rules-list.tsx index d5444eaa..73fd582c 100644 --- a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rules-list.tsx +++ b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/recurring-job-rules-list.tsx @@ -8,7 +8,8 @@ import { listRecurringJobRules, deleteRecurringJobRule } from "@/app/actions/rec import { z } from "zod"; import { ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface"; 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; @@ -19,6 +20,7 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) { const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); const [deletingRule, setDeletingRule] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); const fetchPage = useCallback(async (cursorArg?: string | null) => { const res = await listRecurringJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 }); @@ -39,6 +41,12 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) { return () => { ignore = true; }; }, [fetchPage]); + useEffect(() => { + if (!loading && items.length === 0 && !showCreateForm) { + setShowCreateForm(true); + } + }, [loading, items.length, showCreateForm]); + const loadMore = useCallback(async () => { if (!cursor) return; setLoadingMore(true); @@ -49,6 +57,24 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) { setLoadingMore(false); }, [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) => { if (!window.confirm('Are you sure you want to delete this recurring trigger?')) { return; @@ -125,6 +151,10 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) { return cron; }; + if (showCreateForm) { + return 0} />; + } + return ( - - - +
} > diff --git a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/triggers-tab.tsx b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/triggers-tab.tsx index ede8bf28..e18c42ba 100644 --- a/apps/rowboat/app/projects/[projectId]/manage-triggers/components/triggers-tab.tsx +++ b/apps/rowboat/app/projects/[projectId]/manage-triggers/components/triggers-tab.tsx @@ -205,6 +205,12 @@ export function TriggersTab({ projectId }: { projectId: string }) { } }, [showCreateFlow, loadTriggers]); + useEffect(() => { + if (!loading && !error && triggers.length === 0 && !showCreateFlow) { + setShowCreateFlow(true); + } + }, [loading, error, triggers.length, showCreateFlow]); + useEffect(() => { // No-op: trigger names are now derived from slug locally }, [triggers]); @@ -457,14 +463,16 @@ export function TriggersTab({ projectId }: { projectId: string }) {

Select a Toolkit to Create Trigger

- + {triggers.length > 0 && ( + + )} void; hasExistingTriggers?: boolean }) { const router = useRouter(); const [loading, setLoading] = useState(false); const [messages, setMessages] = useState([ @@ -90,7 +90,11 @@ export function CreateScheduledJobRuleForm({ projectId }: { projectId: string }) input: { messages: convertedMessages }, scheduledTime: scheduledTimeString, }); - router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`); + if (onBack) { + onBack(); + } else { + router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`); + } } catch (error) { console.error("Failed to create scheduled job rule:", error); alert("Failed to create scheduled job rule"); @@ -105,11 +109,17 @@ export function CreateScheduledJobRuleForm({ projectId }: { projectId: string }) - - - + ) : hasExistingTriggers ? ( + + + + ) : null}
CREATE SCHEDULED JOB RULE
diff --git a/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rules-list.tsx b/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rules-list.tsx index b8baf176..cd7ef7be 100644 --- a/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rules-list.tsx +++ b/apps/rowboat/app/projects/[projectId]/manage-triggers/scheduled/components/scheduled-job-rules-list.tsx @@ -9,6 +9,7 @@ import { z } from "zod"; import { ListedRuleItem } from "@/src/application/repositories/scheduled-job-rules.repository.interface"; import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date"; import { PlusIcon, Trash2 } from "lucide-react"; +import { CreateScheduledJobRuleForm } from "./create-scheduled-job-rule-form"; type ListedItem = z.infer; @@ -19,6 +20,7 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) { const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); const [deletingRule, setDeletingRule] = useState(null); + const [showCreateFlow, setShowCreateFlow] = useState(false); const fetchPage = useCallback(async (cursorArg?: string | null) => { const res = await listScheduledJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 }); @@ -39,6 +41,12 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) { return () => { ignore = true; }; }, [fetchPage]); + useEffect(() => { + if (!loading && items.length === 0 && !showCreateFlow) { + setShowCreateFlow(true); + } + }, [loading, items.length, showCreateFlow]); + const loadMore = useCallback(async () => { if (!cursor) return; 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 groups: Record = { Today: [], @@ -103,6 +134,10 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) { return date.toLocaleString(); }; + if (showCreateFlow) { + return 0} />; + } + return ( - - - + } > diff --git a/apps/rowboat/app/projects/[projectId]/playground/app.tsx b/apps/rowboat/app/projects/[projectId]/playground/app.tsx index 05c67dc7..29e230c2 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/app.tsx @@ -17,6 +17,9 @@ export function App({ onPanelClick, triggerCopilotChat, isLiveWorkflow, + activePanel, + onTogglePanel, + onMessageSent, }: { hidden?: boolean; projectId: string; @@ -25,6 +28,9 @@ export function App({ onPanelClick?: () => void; triggerCopilotChat?: (message: string) => void; isLiveWorkflow: boolean; + activePanel: 'playground' | 'copilot'; + onTogglePanel: () => void; + onMessageSent?: () => void; }) { const [counter, setCounter] = useState(0); const [showDebugMessages, setShowDebugMessages] = useState(true); @@ -56,8 +62,34 @@ export function App({ className={`${hidden ? 'hidden' : 'block'}`} variant="playground" tourTarget="playground" - icon={} - title="Chat" + title={ +
+
+ + +
+
+ } subtitle="Chat with your assistant" rightActions={
@@ -112,6 +144,7 @@ export function App({ showDebugMessages={showDebugMessages} triggerCopilotChat={triggerCopilotChat} isLiveWorkflow={isLiveWorkflow} + onMessageSent={onMessageSent} />
diff --git a/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx b/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx index 5f634bdc..8bd4cbf5 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx @@ -22,6 +22,7 @@ export function Chat({ showJsonMode = false, triggerCopilotChat, isLiveWorkflow, + onMessageSent, }: { projectId: string; workflow: z.infer; @@ -31,6 +32,7 @@ export function Chat({ showJsonMode?: boolean; triggerCopilotChat?: (message: string) => void; isLiveWorkflow: boolean; + onMessageSent?: () => void; }) { const conversationId = useRef(null); const [messages, setMessages] = useState[]>([]); @@ -158,6 +160,11 @@ export function Chat({ setMessages(updatedMessages); setError(null); setIsLastInteracted(true); + + // Mark playground as tested when user sends a message + if (onMessageSent) { + onMessageSent(); + } } // clean up event source on component unmount @@ -425,7 +432,7 @@ export function Chat({ )} -
+
{showSuccessMessage && (
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx index 1044810f..ea948813 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx @@ -102,6 +102,8 @@ export function App({ function handleSetMode(mode: 'draft' | 'live') { setMode(mode); + // Reload data to ensure we have the latest workflow data for the current mode + reloadData(); } async function handleRevertToLive() { diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx index 98707fd2..cf398040 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx @@ -1,8 +1,10 @@ "use client"; import React from "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 { ProgressBar, ProgressStep } from "@/components/ui/progress-bar"; interface TopBarProps { localProjectName: string; @@ -12,16 +14,27 @@ interface TopBarProps { publishing: boolean; isLive: boolean; showCopySuccess: boolean; + showBuildModeBanner: boolean; canUndo: boolean; canRedo: boolean; - showCopilot: boolean; + activePanel: 'playground' | 'copilot'; + hasAgentInstructionChanges: boolean; + hasPlaygroundTested: boolean; + hasPublished: boolean; + hasClickedUse: boolean; onUndo: () => void; onRedo: () => void; onDownloadJSON: () => void; onPublishWorkflow: () => void; onChangeMode: (mode: 'draft' | 'live') => void; onRevertToLive: () => void; - onToggleCopilot: () => void; + onTogglePanel: () => void; + onUseAssistantClick: () => void; + onStartNewChatAndFocus: () => void; + onStartBuildTour?: () => void; + onStartTestTour?: () => void; + onStartPublishTour?: () => void; + onStartUseTour?: () => void; } export function TopBar({ @@ -32,20 +45,47 @@ export function TopBar({ publishing, isLive, showCopySuccess, + showBuildModeBanner, canUndo, canRedo, - showCopilot, + activePanel, + hasAgentInstructionChanges, + hasPlaygroundTested, + hasPublished, + hasClickedUse, onUndo, onRedo, onDownloadJSON, onPublishWorkflow, onChangeMode, onRevertToLive, - onToggleCopilot, + onTogglePanel, + onUseAssistantClick, + onStartNewChatAndFocus, + onStartBuildTour, + onStartTestTour, + onStartPublishTour, + onStartUseTour, }: TopBarProps) { const router = useRouter(); const params = useParams(); 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 (
@@ -70,106 +110,156 @@ export function TopBar({ classNames={{ base: "max-w-xs", 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" }} />
- -
- -
- {publishing && } - {isLive &&
- - Live workflow -
} - {!isLive &&
- - Draft workflow -
} - {/* Download JSON icon button, with tooltip, to the left of the menu */} - - - -
+ {/* Show divider and CTA only in live view */} + {isLive &&
} + {isLive ? ( + + ) : null}
- {showCopySuccess &&
-
Copied to clipboard
-
} + + {/* Progress Bar - Center */} +
+ { + if (step.id === 1 && onStartBuildTour) onStartBuildTour(); + if (step.id === 2 && onStartTestTour) onStartTestTour(); + if (step.id === 3 && onStartPublishTour) onStartPublishTour(); + if (step.id === 4 && onStartUseTour) onStartUseTour(); + }} + /> +
+ + {/* Right side buttons */}
- {isLive &&
-
- - This version is locked. Changes applied will not be reflected. + {showCopySuccess &&
+
Copied to clipboard
+
} + + {showBuildModeBanner &&
+ +
+ Switched to draft mode. You can now make changes to your workflow.
} + {!isLive && <> - - + + } {/* Deploy CTA - always visible */} -
+
{isLive ? ( - - - - - - } - onPress={() => { if (projectId) { router.push(`/projects/${projectId}/config`); } }} - > - API & SDK Settings - - } - onPress={() => { if (projectId) { router.push(`/projects/${projectId}/manage-triggers`); } }} + <> + + + + + + } + onPress={() => { + onUseAssistantClick(); + onStartNewChatAndFocus(); + }} + > + Chat with Assistant + + } + onPress={() => { + onUseAssistantClick(); + if (projectId) { router.push(`/projects/${projectId}/config`); } + }} + > + API & SDK Settings + + } + onPress={() => { + onUseAssistantClick(); + if (projectId) { router.push(`/projects/${projectId}/manage-triggers`); } + }} + > + Manage Triggers + + + + + {/* Live workflow label moved here */} +
+ {publishing && } +
+ + Live workflow +
+ + + +
+ ) : ( <> +
@@ -203,31 +293,34 @@ export function TopBar({ +
+ + {/* Moved draft/live labels and download button here */} +
+ {publishing && } + {isLive &&
+ + Live workflow +
} + {!isLive &&
+ + Draft workflow +
} + + + +
)}
- - {isLive &&
- -
} - - {!isLive && } +
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx b/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx index f939e029..27e7f02e 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx @@ -207,8 +207,8 @@ const ListItemWithMenu = ({ }; const StartLabel = () => ( -
- Start +
+ START
); @@ -1175,7 +1175,7 @@ export const EntityList = forwardRef< tourTarget="entity-prompts" className={clsx( "h-full", - !expandedPanels.prompts && "h-[53px]!" + !expandedPanels.prompts && "h-[61px]!" )} title={
@@ -1208,7 +1208,7 @@ export const EntityList = forwardRef< } > {expandedPanels.prompts && ( -
+
{prompts.length > 0 ? (
@@ -1631,17 +1631,6 @@ const ComposioCard = ({
))} - {/* More tools option */} -
)}
@@ -2116,4 +2105,4 @@ function AddVariableModal({ isOpen, onClose, onConfirm, initialName, initialValu ); -} \ No newline at end of file +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index a1ab84fd..dcd4ce64 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx @@ -61,6 +61,7 @@ interface StateItem { chatKey: number; lastUpdatedAt: string; isLive: boolean; + agentInstructionsChanged: boolean; } interface State { @@ -73,6 +74,15 @@ interface State { export type Action = { type: "update_workflow_name"; 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"; publishing: boolean; @@ -238,6 +248,19 @@ function reducer(state: State, action: Action): State { }); 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": { newState = produce(state, draft => { draft.present.saving = action.saving; @@ -335,9 +358,6 @@ function reducer(state: State, action: Action): State { draft.selection = null; break; case "add_agent": { - if (isLive) { - break; - } let newAgentName = "New agent"; if (draft.workflow?.agents.some((agent) => agent.name === newAgentName)) { newAgentName = `New agent ${draft.workflow.agents.filter((agent) => @@ -368,9 +388,6 @@ function reducer(state: State, action: Action): State { break; } case "add_tool": { - if (isLive) { - break; - } let newToolName = "new_tool"; if (draft.workflow?.tools.some((tool) => tool.name === newToolName)) { newToolName = `new_tool_${draft.workflow.tools.filter((tool) => @@ -396,9 +413,6 @@ function reducer(state: State, action: Action): State { break; } case "add_prompt": { - if (isLive) { - break; - } let newPromptName = "New Variable"; if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) { newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) => @@ -419,9 +433,6 @@ function reducer(state: State, action: Action): State { break; } case "add_prompt_no_select": { - if (isLive) { - break; - } let newPromptName = "New Variable"; if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) { 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) case "add_pipeline": { - if (isLive) { - break; - } if (!draft.workflow.pipelines) { draft.workflow.pipelines = []; @@ -521,9 +529,6 @@ function reducer(state: State, action: Action): State { break; } case "delete_agent": - if (isLive) { - break; - } // Remove the agent draft.workflow.agents = draft.workflow.agents.filter( (agent) => agent.name !== action.name @@ -568,9 +573,6 @@ function reducer(state: State, action: Action): State { draft.chatKey++; break; case "delete_tool": - if (isLive) { - break; - } draft.workflow.tools = draft.workflow.tools.filter( (tool) => tool.name !== action.name ); @@ -579,9 +581,6 @@ function reducer(state: State, action: Action): State { draft.chatKey++; break; case "delete_prompt": - if (isLive) { - break; - } draft.workflow.prompts = draft.workflow.prompts.filter( (prompt) => prompt.name !== action.name ); @@ -590,9 +589,6 @@ function reducer(state: State, action: Action): State { draft.chatKey++; break; case "delete_pipeline": - if (isLive) { - break; - } if (draft.workflow.pipelines) { // Find the pipeline to get its associated agents const pipelineToDelete = draft.workflow.pipelines.find( @@ -649,9 +645,6 @@ function reducer(state: State, action: Action): State { draft.chatKey++; break; case "update_pipeline": { - if (isLive) { - break; - } if (draft.workflow.pipelines) { draft.workflow.pipelines = draft.workflow.pipelines.map(pipeline => pipeline.name === action.name ? { ...pipeline, ...action.pipeline } : pipeline @@ -663,8 +656,9 @@ function reducer(state: State, action: Action): State { break; } case "update_agent": { - if (isLive) { - break; + // Check if instructions are being changed + if (action.agent.instructions !== undefined) { + draft.agentInstructionsChanged = true; } // update agent data @@ -724,9 +718,6 @@ function reducer(state: State, action: Action): State { break; } case "update_tool": - if (isLive) { - break; - } // update tool data draft.workflow.tools = draft.workflow.tools.map((tool) => @@ -769,9 +760,6 @@ function reducer(state: State, action: Action): State { draft.chatKey++; break; case "update_prompt": - if (isLive) { - break; - } // update prompt data draft.workflow.prompts = draft.workflow.prompts.map((prompt) => @@ -814,9 +802,6 @@ function reducer(state: State, action: Action): State { draft.chatKey++; break; case "update_prompt_no_select": - if (isLive) { - break; - } // update prompt data draft.workflow.prompts = draft.workflow.prompts.map((prompt) => @@ -855,18 +840,12 @@ function reducer(state: State, action: Action): State { draft.chatKey++; break; case "toggle_agent": - if (isLive) { - break; - } draft.workflow.agents = draft.workflow.agents.map(agent => agent.name === action.name ? { ...agent, disabled: !agent.disabled } : agent ); draft.chatKey++; break; case "set_main_agent": - if (isLive) { - break; - } draft.workflow.startAgent = action.name; draft.pendingChanges = true; draft.chatKey++; @@ -955,6 +934,7 @@ export function WorkflowEditor({ chatKey: 0, lastUpdatedAt: workflow.lastUpdatedAt, isLive, + agentInstructionsChanged: false, } }); @@ -965,10 +945,47 @@ export function WorkflowEditor({ const saveQueue = useRef[]>([]); const saving = useRef(false); const [showCopySuccess, setShowCopySuccess] = useState(false); - const [showCopilot, setShowCopilot] = useState(true); - const [copilotWidth, setCopilotWidth] = useState(PANEL_RATIOS.copilot); + const [activePanel, setActivePanel] = useState<'playground' | 'copilot'>('copilot'); const [isInitialState, setIsInitialState] = useState(true); + const [showBuildModeBanner, setShowBuildModeBanner] = useState(false); + const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [configKey, setConfigKey] = useState(0); + const [lastWorkflowId, setLastWorkflowId] = useState(null); 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 entityListRef = useRef<{ openDataSourcesModal: () => void } | null>(null); @@ -986,6 +1003,75 @@ export function WorkflowEditor({ const [projectNameError, setProjectNameError] = useState(null); const [isEditingProjectName, setIsEditingProjectName] = useState(false); const [pendingProjectName, setPendingProjectName] = useState(null); + + // Build progress tracking - persists once set to true (guard SSR) + const [hasAgentInstructionChanges, setHasAgentInstructionChanges] = useState(() => { + 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(() => { + 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(() => { + 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(() => { + 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 // useEffect(() => { @@ -1010,7 +1096,7 @@ export function WorkflowEditor({ // Function to trigger copilot chat const triggerCopilotChat = useCallback((message: string) => { - setShowCopilot(true); + setActivePanel('copilot'); // Small delay to ensure copilot is mounted setTimeout(() => { copilotRef.current?.handleUserMessage(message); @@ -1028,14 +1114,14 @@ export function WorkflowEditor({ const prompt = localStorage.getItem(`project_prompt_${projectId}`); console.log('init project prompt', prompt); if (prompt) { - setShowCopilot(true); + setActivePanel('copilot'); } }, [projectId]); - // Hide copilot when switching to live mode + // Switch to playground when switching to live mode useEffect(() => { if (isLive) { - setShowCopilot(false); + setActivePanel('playground'); } }, [isLive]); @@ -1053,6 +1139,13 @@ export function WorkflowEditor({ } }, [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) { dispatch({ type: "select_agent", name }); } @@ -1093,15 +1186,15 @@ export function WorkflowEditor({ ...agent, model: agent.model || defaultModel || "gpt-4.1" }; - dispatch({ type: "add_agent", agent: agentWithModel }); + dispatchGuarded({ type: "add_agent", agent: agentWithModel }); } function handleAddTool(tool: Partial> = {}) { - dispatch({ type: "add_tool", tool }); + dispatchGuarded({ type: "add_tool", tool }); } function handleAddPrompt(prompt: Partial> = {}) { - dispatch({ type: "add_prompt", prompt }); + dispatchGuarded({ type: "add_prompt", prompt }); } function handleSelectPipeline(name: string) { @@ -1109,7 +1202,7 @@ export function WorkflowEditor({ } function handleAddPipeline(pipeline: Partial> = {}) { - dispatch({ type: "add_pipeline", pipeline, defaultModel }); + dispatchGuarded({ type: "add_pipeline", pipeline, defaultModel }); } function handleDeletePipeline(name: string) { @@ -1130,12 +1223,12 @@ export function WorkflowEditor({ }; // First add the agent - dispatch({ type: "add_agent", agent: agentWithModel }); + dispatchGuarded({ type: "add_agent", agent: agentWithModel }); // Then add it to the pipeline const pipeline = state.present.workflow.pipelines?.find(p => p.name === pipelineName); if (pipeline) { - dispatch({ + dispatchGuarded({ type: "update_pipeline", name: pipelineName, pipeline: { @@ -1150,6 +1243,10 @@ export function WorkflowEditor({ } function handleUpdateAgent(name: string, agent: Partial>) { + // Check if instructions are being changed + if (agent.instructions !== undefined) { + markAgentInstructionsChanged(); + } dispatch({ type: "update_agent", name, agent }); } @@ -1225,8 +1322,18 @@ export function WorkflowEditor({ } async function handlePublishWorkflow() { - await publishWorkflow(projectId, state.present.workflow); - onChangeMode('live'); + dispatch({ type: 'set_publishing', publishing: true }); + 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() { @@ -1326,6 +1433,105 @@ export function WorkflowEditor({ 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) => { if (value.length === 0) { setProjectNameError("Project name cannot be empty"); @@ -1386,6 +1592,39 @@ export function WorkflowEditor({ onSelectPrompt: handleSelectPrompt, }}>
+ {/* Live Workflow Edit Modal */} + + + +
+ + Edit Live Workflow +
+
+ +

+ Seems like you're trying to edit the live workflow. Only the draft version can be modified. Changes will not be saved. +

+
+ + + + +
+
+ {/* Top Bar - Isolated like sidebar */} 0} canRedo={state.currentIndex < state.patches.length} - showCopilot={showCopilot} - onUndo={() => dispatch({ type: "undo" })} - onRedo={() => dispatch({ type: "redo" })} + activePanel={activePanel} + hasAgentInstructionChanges={hasAgentInstructionChanges} + hasPlaygroundTested={hasPlaygroundTested} + hasPublished={hasPublished} + hasClickedUse={hasClickedUse} + onUndo={() => dispatchGuarded({ type: "undo" })} + onRedo={() => dispatchGuarded({ type: "redo" })} onDownloadJSON={handleDownloadJSON} onPublishWorkflow={handlePublishWorkflow} onChangeMode={onChangeMode} 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 */} - +
- + + + {/* Config Panel - always rendered, visibility controlled */} - - {showCopilot && ( - <> - - setCopilotWidth(size)} - > - 0 - ? { type: 'chat', messages: chatMessages } - : undefined - } - isInitialState={isInitialState} - dataSources={dataSources} - /> - - - )} + {/* Second handle - between config and chat panels */} + + + {/* ChatApp/Copilot Panel - always visible */} + +
+ +
+
+ 0 + ? { type: 'chat', messages: chatMessages } + : undefined + } + isInitialState={isInitialState} + dataSources={dataSources} + activePanel={activePanel} + onTogglePanel={handleTogglePanel} + /> +
+
{USE_PRODUCT_TOUR && showTour && ( setShowTour(false)} /> )} + {showBuildTour && ( + { + if (step.target === 'copilot') setActivePanel('copilot'); + }} + onComplete={() => setShowBuildTour(false)} + /> + )} + {showTestTour && ( + { + if (index === 0) setActivePanel('playground'); + if (index === 1) setActivePanel('copilot'); + }} + onComplete={() => setShowTestTour(false)} + /> + )} + {showUseTour && ( + { + if (index === 0) setActivePanel('playground'); + }} + onComplete={() => setShowUseTour(false)} + /> + )} + {showPublishTour && ( + setShowPublishTour(false)} + /> + )} {/* Revert to Live Confirmation Modal */} diff --git a/apps/rowboat/app/projects/components/build-assistant-section.tsx b/apps/rowboat/app/projects/components/build-assistant-section.tsx index 94200964..5eddfb0e 100644 --- a/apps/rowboat/app/projects/components/build-assistant-section.tsx +++ b/apps/rowboat/app/projects/components/build-assistant-section.tsx @@ -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 = { "Blog assistant": { @@ -363,7 +363,7 @@ export function BuildAssistantSection() {
-
+
{projectsLoading ? (
Loading assistants... @@ -374,7 +374,7 @@ export function BuildAssistantSection() {
) : ( <> -
+
{currentProjects.map((project) => ( ); -} \ No newline at end of file +} diff --git a/apps/rowboat/components/common/compose-box-copilot.tsx b/apps/rowboat/components/common/compose-box-copilot.tsx index 31e49563..643fd0e6 100644 --- a/apps/rowboat/components/common/compose-box-copilot.tsx +++ b/apps/rowboat/components/common/compose-box-copilot.tsx @@ -85,11 +85,11 @@ export function ComposeBoxCopilot({ group-hover:opacity-100 transition-opacity"> Press ⌘ + Enter to send
- {/* Outer container with padding */} -
{/* Textarea */} -
+