From 788610d08b1738cf01dd709834847c33536e5030 Mon Sep 17 00:00:00 2001 From: akhisud3195 Date: Wed, 3 Sep 2025 18:39:50 +0400 Subject: [PATCH] Auto-switch live mode to draft mode when changes are made to workflow or build mode is toggled on --- .../workflow/components/TopBar.tsx | 8 + .../[projectId]/workflow/workflow_editor.tsx | 203 ++++++++++++------ 2 files changed, 147 insertions(+), 64 deletions(-) diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx index 0261cd5d..8712a2e1 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx @@ -12,6 +12,7 @@ interface TopBarProps { publishing: boolean; isLive: boolean; showCopySuccess: boolean; + showBuildModeBanner: boolean; canUndo: boolean; canRedo: boolean; activePanel: 'playground' | 'copilot'; @@ -32,6 +33,7 @@ export function TopBar({ publishing, isLive, showCopySuccess, + showBuildModeBanner, canUndo, canRedo, activePanel, @@ -92,6 +94,12 @@ export function TopBar({ {showCopySuccess &&
Copied to clipboard
} + {showBuildModeBanner &&
+ +
+ Switched to draft mode to enable Build panel. You can now make changes to your workflow. +
+
}
{!isLive && <> diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index 4111238e..f90f2d1c 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; + showWorkflowChangeBanner: 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,24 @@ 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 "clear_workflow_change_banner": { + newState = produce(state, draft => { + draft.present.showWorkflowChangeBanner = false; + }); + break; + } case "set_saving": { newState = produce(state, draft => { draft.present.saving = action.saving; @@ -293,9 +321,23 @@ function reducer(state: State, action: Action): State { break; } default: { + // Check if this is a workflow modification action in live mode + const isWorkflowModification = [ + "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" + ].includes(action.type); + const [nextState, patches, inversePatches] = produceWithPatches( state.present, (draft) => { + // If this is a workflow modification in live mode, switch to draft + if (isWorkflowModification && isLive) { + draft.isLive = false; + draft.showWorkflowChangeBanner = true; + } + switch (action.type) { case "select_agent": draft.selection = { @@ -335,9 +377,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 +407,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 +432,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 +452,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 +470,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 +548,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 +592,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 +600,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 +608,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 +664,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,9 +675,6 @@ function reducer(state: State, action: Action): State { break; } case "update_agent": { - if (isLive) { - break; - } // update agent data draft.workflow.agents = draft.workflow.agents.map((agent) => @@ -724,9 +733,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 +775,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 +817,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 +855,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 +949,7 @@ export function WorkflowEditor({ chatKey: 0, lastUpdatedAt: workflow.lastUpdatedAt, isLive, + showWorkflowChangeBanner: false, } }); @@ -967,6 +962,7 @@ export function WorkflowEditor({ const [showCopySuccess, setShowCopySuccess] = useState(false); const [activePanel, setActivePanel] = useState<'playground' | 'copilot'>('copilot'); const [isInitialState, setIsInitialState] = useState(true); + const [showBuildModeBanner, setShowBuildModeBanner] = useState(false); const [showTour, setShowTour] = useState(true); const copilotRef = useRef<{ handleUserMessage: (message: string) => void }>(null); const entityListRef = useRef<{ openDataSourcesModal: () => void } | null>(null); @@ -1038,6 +1034,27 @@ export function WorkflowEditor({ } }, [isLive]); + // If reducer switched to draft (internal flag) while outer prop is still live, + // trigger external mode change and show banner. Guard against publish in-flight. + useEffect(() => { + if (isLive && state.present.isLive === false && !state.present.publishing) { + onChangeMode('draft'); + setShowBuildModeBanner(true); + setTimeout(() => setShowBuildModeBanner(false), 5000); + } + }, [isLive, state.present.isLive, state.present.publishing, onChangeMode]); + + // Show banner when switching from live to draft due to workflow changes + useEffect(() => { + if (state.present.showWorkflowChangeBanner) { + setShowBuildModeBanner(true); + // Auto-hide banner after 5 seconds + setTimeout(() => setShowBuildModeBanner(false), 5000); + // Clear the flag + dispatch({ type: "clear_workflow_change_banner" }); + } + }, [state.present.showWorkflowChangeBanner]); + // Reset initial state when user interacts with copilot or opens other menus useEffect(() => { if (state.present.selection !== null) { @@ -1092,15 +1109,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) { @@ -1108,7 +1125,7 @@ export function WorkflowEditor({ } function handleAddPipeline(pipeline: Partial> = {}) { - dispatch({ type: "add_pipeline", pipeline, defaultModel }); + dispatchGuarded({ type: "add_pipeline", pipeline, defaultModel }); } function handleDeletePipeline(name: string) { @@ -1129,12 +1146,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: { @@ -1200,6 +1217,7 @@ export function WorkflowEditor({ } function handleReorderAgents(agents: z.infer[]) { + handleWorkflowChange(); // Save order to localStorage const orderMap = agents.reduce((acc, agent, index) => { acc[agent.name] = index; @@ -1212,6 +1230,7 @@ export function WorkflowEditor({ } function handleReorderPipelines(pipelines: z.infer[]) { + handleWorkflowChange(); // Save order to localStorage const orderMap = pipelines.reduce((acc, pipeline, index) => { acc[pipeline.name] = index; @@ -1224,8 +1243,15 @@ 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); + // 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() { @@ -1325,6 +1351,54 @@ 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) => { + if (WORKFLOW_MOD_ACTIONS.has((action as any).type) && isLive && !state.present.publishing) { + onChangeMode('draft'); + setShowBuildModeBanner(true); + setTimeout(() => setShowBuildModeBanner(false), 5000); + } + dispatch(action); + }, [WORKFLOW_MOD_ACTIONS, isLive, state.present.publishing, onChangeMode, dispatch]); + + function handleTogglePanel() { + if (isLive && activePanel === 'playground') { + // User is trying to switch to Build mode in live mode + onChangeMode('draft'); + setActivePanel('copilot'); // Switch to Build mode as intended + setShowBuildModeBanner(true); + // Auto-hide banner after 5 seconds + setTimeout(() => setShowBuildModeBanner(false), 5000); + } else { + setActivePanel(activePanel === 'playground' ? 'copilot' : 'playground'); + } + } + + function handleWorkflowChange() { + if (isLive) { + // User is making changes in live mode - switch to draft + onChangeMode('draft'); + setShowBuildModeBanner(true); + // Auto-hide banner after 5 seconds + setTimeout(() => setShowBuildModeBanner(false), 5000); + } + } + const validateProjectName = (value: string) => { if (value.length === 0) { setProjectNameError("Project name cannot be empty"); @@ -1394,16 +1468,17 @@ export function WorkflowEditor({ publishing={state.present.publishing} isLive={isLive} showCopySuccess={showCopySuccess} + showBuildModeBanner={showBuildModeBanner} canUndo={state.currentIndex > 0} canRedo={state.currentIndex < state.patches.length} activePanel={activePanel} - onUndo={() => dispatch({ type: "undo" })} - onRedo={() => dispatch({ type: "redo" })} + onUndo={() => dispatchGuarded({ type: "undo" })} + onRedo={() => dispatchGuarded({ type: "redo" })} onDownloadJSON={handleDownloadJSON} onPublishWorkflow={handlePublishWorkflow} onChangeMode={onChangeMode} onRevertToLive={handleRevertToLive} - onTogglePanel={() => setActivePanel(activePanel === 'playground' ? 'copilot' : 'playground')} + onTogglePanel={handleTogglePanel} /> {/* Content Area */} @@ -1478,7 +1553,7 @@ export function WorkflowEditor({ triggerCopilotChat={triggerCopilotChat} isLiveWorkflow={isLive} activePanel={activePanel} - onTogglePanel={() => setActivePanel(activePanel === 'playground' ? 'copilot' : 'playground')} + onTogglePanel={handleTogglePanel} />
@@ -1503,7 +1578,7 @@ export function WorkflowEditor({ isInitialState={isInitialState} dataSources={dataSources} activePanel={activePanel} - onTogglePanel={() => setActivePanel(activePanel === 'playground' ? 'copilot' : 'playground')} + onTogglePanel={handleTogglePanel} />
{state.present.selection?.type === "agent" && { dispatchGuarded({ type: "update_agent", name: state.present.selection!.name, agent: update }); }} handleClose={handleUnselectAgent} useRag={useRag} triggerCopilotChat={triggerCopilotChat} @@ -1534,7 +1609,7 @@ export function WorkflowEditor({ usedToolNames={new Set([ ...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} />; })()} @@ -1545,7 +1620,7 @@ export function WorkflowEditor({ tools={state.present.workflow.tools} prompts={state.present.workflow.prompts} 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} />} {state.present.selection?.type === "datasource" &&