diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index 1d878d6977..6c4a9d95ca 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -183,16 +183,19 @@ function LandingScreenWrapper({ aiAssistantEnabled: boolean; }) { const showLandingScreen = useShowLandingScreen(); - const { openYAMLImportModal } = useUICommands(); + const { openYAMLImportModal, dismissLandingScreen, openAIAssistantPanel } = + useUICommands(); if (!showLandingScreen) return null; return ( <> - {/* TODO-AI-FIRST Stubs — wired up in Issues #4857 (Build with AI), #4858 (Browse Templates) */} {}} + onBuildWithAI={(prompt: string) => { + dismissLandingScreen(); + openAIAssistantPanel(prompt); + }} onBuildFromScratch={() => {}} onBrowseTemplates={() => {}} onImportYAML={openYAMLImportModal} diff --git a/assets/js/collaborative-editor/components/AIAssistantPanel.tsx b/assets/js/collaborative-editor/components/AIAssistantPanel.tsx index 5375845339..c94033c73b 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanel.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from 'react'; import { cn } from '#/utils/cn'; +import { Tooltip } from '../../components/Tooltip'; import { useAIStorageKey, useAISessionType, @@ -15,11 +16,10 @@ import { useSelectedStepId, useSelectedRunId } from '../hooks/useHistory'; import { ChatInput } from './ChatInput'; import { DisclaimerScreen } from './DisclaimerScreen'; import { SessionList } from './SessionList'; -import { Tooltip } from '../../components/Tooltip'; interface AIAssistantPanelProps { isOpen: boolean; - onClose: () => void; + onClose?: () => void; onNewConversation?: () => void; onSessionSelect?: (sessionId: string) => void; onShowSessions?: () => void; @@ -233,7 +233,7 @@ export function AIAssistantPanel({ if (onShowSessions) { onShowSessions(); } - } else { + } else if (onClose) { onClose(); } }; @@ -390,27 +390,31 @@ export function AIAssistantPanel({ )} - - - + + + )} diff --git a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx index b2c158545a..2b0397e5fc 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx @@ -61,7 +61,7 @@ import { useWorkflowState, } from '../hooks/useWorkflow'; import { useKeyboardShortcut } from '../keyboard'; -import type { JobCodeContext } from '../types/ai-assistant'; +import type { JobCodeContext, Message } from '../types/ai-assistant'; import { Z_INDEX } from '../utils/constants'; import { prepareWorkflowForSerialization, @@ -103,6 +103,9 @@ export function AIAssistantPanelWrapper({ const isPinnedVersion = currentVersion !== undefined && currentVersion !== null; + const { isReadOnly } = useWorkflowReadOnly(); + const isNewWorkflow = useIsNewWorkflow(); + // Track IDE state changes to re-focus chat input when IDE closes const isIDEOpen = params.panel === 'editor'; const [focusTrigger, setFocusTrigger] = useState(0); @@ -128,7 +131,7 @@ export function AIAssistantPanelWrapper({ toggleAIAssistantPanel(); }, 0, - { enabled: !isPinnedVersion && aiAssistantEnabled } + { enabled: !isPinnedVersion && aiAssistantEnabled && !isNewWorkflow } ); const aiStore = useAIStore(); @@ -157,10 +160,7 @@ export function AIAssistantPanelWrapper({ const workflow = useWorkflowState(state => state.workflow); const limits = useLimits(); - // Check readonly state and new workflow status // AI can apply changes if: not readonly OR is a new workflow (being created) - const { isReadOnly } = useWorkflowReadOnly(); - const isNewWorkflow = useIsNewWorkflow(); const canApplyChanges = !isReadOnly || isNewWorkflow; const isWriteDisabled = !canApplyChanges; @@ -548,6 +548,7 @@ export function AIAssistantPanelWrapper({ startApplyingJobCode, doneApplyingJobCode, updateJob, + saveWorkflow, } = useWorkflowActions(); // Get applying state from workflow store for disabling Apply button across all users @@ -559,6 +560,24 @@ export function AIAssistantPanelWrapper({ state => state.applyingJobCodeMessageId ); + const onValidationError = useCallback( + (errorMessage: string) => { + const message: Message = { + id: crypto.randomUUID(), + role: 'assistant', + content: errorMessage, + status: 'error', + inserted_at: new Date().toISOString(), + }; + aiStore._addMessage(message); + }, + [aiStore] + ); + + // Track whether we applied via streaming so the auto-apply effect in the + // hook can skip the duplicate when the final new_message arrives + const appliedViaStreamingRef = useRef(false); + // Hook to handle workflow/job code application logic const { handleApplyWorkflow, handlePreviewJobCode, handleApplyJobCode } = useAIWorkflowApplications({ @@ -573,6 +592,8 @@ export function AIAssistantPanelWrapper({ : null, currentUserId: user?.id, aiMode, + isNewWorkflow, + onValidationError, workflowActions: { importWorkflow, startApplyingWorkflow, @@ -580,6 +601,7 @@ export function AIAssistantPanelWrapper({ startApplyingJobCode, doneApplyingJobCode, updateJob, + saveWorkflow, }, monacoRef, jobs, @@ -589,6 +611,7 @@ export function AIAssistantPanelWrapper({ previewingMessageId, setApplyingMessageId, appliedMessageIdsRef, + appliedViaStreamingRef, }); // Auto-preview job code when AI responds with code @@ -608,9 +631,6 @@ export function AIAssistantPanelWrapper({ const appliedStreamingChangesRef = useRef | null>( null ); - // Track whether we applied via streaming so we can skip the duplicate - // auto-apply when the final new_message arrives - const appliedViaStreamingRef = useRef(false); useEffect(() => { if (!streamingChanges || !canApplyChanges) return; // Avoid re-applying the same streaming changes object @@ -626,7 +646,6 @@ export function AIAssistantPanelWrapper({ } else if (aiMode?.page === 'job_code' && 'code' in streamingChanges) { const code = streamingChanges['code'] as string; if (code) { - appliedViaStreamingRef.current = true; handlePreviewJobCode(code, '__streaming__'); } } @@ -638,22 +657,6 @@ export function AIAssistantPanelWrapper({ handlePreviewJobCode, ]); - // When a new assistant message with code arrives after we already applied - // via streaming, mark it as already applied to prevent duplicate auto-apply - // and update previewingMessageId to the real ID to prevent diff flicker - useEffect(() => { - if (!appliedViaStreamingRef.current) return; - - const latestAssistantMessage = [...messages] - .reverse() - .find(m => m.role === 'assistant' && m.code && m.status === 'success'); - - if (latestAssistantMessage) { - appliedMessageIdsRef.current.add(latestAssistantMessage.id); - appliedViaStreamingRef.current = false; - } - }, [messages, appliedMessageIdsRef]); - return (
- + {message.status === 'error' && + !isStreaming(message) && + message.content.trim() ? ( +
+
+ +

+ {message.content} +

+
+
+ ) : ( + + )} {/* Status (e.g. "Generating code...") Apollo may stream after the text answer, while we wait for code. Same @@ -645,33 +661,35 @@ export function MessageList({
)} - {!isStreaming(message) && message.status === 'error' && ( -
- - - Failed to send message. Please try again. - - {onRetryMessage && ( - - )} -
- )} + {!isStreaming(message) && + message.status === 'error' && + !message.content.trim() && ( +
+ + + Failed to send message. Please try again. + + {onRetryMessage && ( + + )} +
+ )} {!isStreaming(message) && message.status === 'processing' && (
diff --git a/assets/js/collaborative-editor/components/WorkflowEditor.tsx b/assets/js/collaborative-editor/components/WorkflowEditor.tsx index bf2dc3b50e..730a980189 100644 --- a/assets/js/collaborative-editor/components/WorkflowEditor.tsx +++ b/assets/js/collaborative-editor/components/WorkflowEditor.tsx @@ -505,6 +505,7 @@ export function WorkflowEditor({ {/* Show placeholder when workflow is empty and landing screen is not covering it */} {isNewWorkflow && !showLandingScreen && + !isAIAssistantPanelOpen && workflow.jobs.length === 0 && workflow.triggers.length === 0 && (
diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index 729775704e..c31ee1fdc9 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -104,6 +104,8 @@ export function useAIWorkflowApplications({ currentSession, currentUserId, aiMode, + isNewWorkflow, + onValidationError, workflowActions, monacoRef, jobs, @@ -113,6 +115,7 @@ export function useAIWorkflowApplications({ previewingMessageId, setApplyingMessageId, appliedMessageIdsRef, + appliedViaStreamingRef, }: { sessionId: string | null; page: SessionType | null; @@ -122,6 +125,8 @@ export function useAIWorkflowApplications({ } | null; currentUserId: string | undefined; aiMode: AIModeResult | null; + isNewWorkflow: boolean; + onValidationError?: (message: string) => void; workflowActions: { importWorkflow: (state: YAMLWorkflowState) => Promise; startApplyingWorkflow: (messageId: string) => Promise; @@ -129,6 +134,7 @@ export function useAIWorkflowApplications({ startApplyingJobCode: (messageId: string) => Promise; doneApplyingJobCode: (messageId: string) => Promise; updateJob: (jobId: string, updates: { body: string }) => void; + saveWorkflow: (options?: { silent?: boolean }) => Promise; }; monacoRef: RefObject | null; jobs: Job[]; @@ -138,6 +144,7 @@ export function useAIWorkflowApplications({ previewingMessageId: string | null; setApplyingMessageId: (id: string | null) => void; appliedMessageIdsRef: React.MutableRefObject>; + appliedViaStreamingRef?: React.MutableRefObject; }) { const { importWorkflow, @@ -146,6 +153,7 @@ export function useAIWorkflowApplications({ startApplyingJobCode, doneApplyingJobCode, updateJob, + saveWorkflow, } = workflowActions; /** @@ -161,6 +169,12 @@ export function useAIWorkflowApplications({ hasLoadedSessionRef.current = false; }, [sessionId]); + // Stable ref so the save-failure Retry toast always calls the latest version + // of handleApplyWorkflow rather than the one captured when the toast fired. + const handleApplyWorkflowRef = useRef< + ((yaml: string, messageId: string) => Promise) | null + >(null); + /** * Apply workflow YAML to the canvas * @@ -185,9 +199,12 @@ export function useAIWorkflowApplications({ // Returns false if coordination failed (other users won't be notified) const coordinated = await startApplyingWorkflow(messageId); + // Track outcomes independently: applySucceeded covers parse/validate/import; + // saveSucceeded covers the subsequent save for new workflows. + let applySucceeded = false; + let saveSucceeded = true; try { const workflowSpec = parseWorkflowYAML(yaml); - validateIds(workflowSpec); // IDs are already in the YAML from AI (sent with IDs, like legacy editor) @@ -199,21 +216,54 @@ export function useAIWorkflowApplications({ ); await importWorkflow(workflowStateWithCreds); + applySucceeded = true; + + if (isNewWorkflow) { + try { + await saveWorkflow({ silent: true }); + } catch (saveError) { + saveSucceeded = false; + console.error('[AI Assistant] Failed to save workflow:', saveError); + notifications.alert({ + title: 'Failed to save workflow', + description: + saveError instanceof Error + ? saveError.message + : 'Unknown error occurred', + action: { + label: 'Retry', + onClick: () => + void handleApplyWorkflowRef.current?.(yaml, messageId), + }, + }); + } + } } catch (error) { console.error('[AI Assistant] Failed to apply workflow:', error); - notifications.alert({ - title: 'Failed to apply workflow', - description: - error instanceof Error ? error.message : 'Invalid workflow YAML', - }); + const errorMessage = + error instanceof Error ? error.message : 'Invalid workflow YAML'; + + if (isNewWorkflow && onValidationError) { + onValidationError(errorMessage); + } else { + notifications.alert({ + title: 'Failed to apply workflow', + description: errorMessage, + }); + } } finally { setApplyingMessageId(null); - // Only signal completion if we successfully coordinated - // (otherwise other users weren't notified of the start) + // Always signal completion when coordinated so collaborators aren't + // left stuck in "APPLYING..." state, even if apply itself failed. if (coordinated) { await doneApplyingWorkflow(messageId); - flowEvents.dispatch('fit-view'); + // Only fit-view when the canvas was actually updated and persisted. + // Skip when importWorkflow failed (applySucceeded false) or when + // save failed so we don't zoom in on an unpersisted workflow. + if (applySucceeded && saveSucceeded) { + flowEvents.dispatch('fit-view'); + } } } }, @@ -224,9 +274,15 @@ export function useAIWorkflowApplications({ doneApplyingWorkflow, jobs, setApplyingMessageId, + isNewWorkflow, + onValidationError, + saveWorkflow, ] ); + // Keep ref pointing at the latest callback so the Retry toast closure never goes stale + handleApplyWorkflowRef.current = handleApplyWorkflow; + /** * Preview job code diff in Monaco editor * @@ -406,6 +462,9 @@ export function useAIWorkflowApplications({ messagesWithCode.forEach(msg => { appliedMessageIdsRef.current.add(msg.id); }); + // Streaming may have set this before the session finished loading. + // Clear it now so the guard doesn't silently skip the next real response. + if (appliedViaStreamingRef) appliedViaStreamingRef.current = false; return; } @@ -415,6 +474,16 @@ export function useAIWorkflowApplications({ latestMessage?.code && !appliedMessageIdsRef.current.has(latestMessage.id) ) { + appliedMessageIdsRef.current.add(latestMessage.id); + + // Streaming already applied this YAML — skip the re-import to avoid + // a transient dirty state (Y.Doc write → unsaved red dot) between + // the import and save that would otherwise follow. + if (appliedViaStreamingRef?.current) { + appliedViaStreamingRef.current = false; + return; + } + // Find the user message that triggered this AI response // Look for the most recent user message before this assistant message const latestMessageIndex = messages.findIndex( @@ -433,8 +502,6 @@ export function useAIWorkflowApplications({ // Fallback: if no user_id on message (legacy), allow apply !precedingUserMessage?.user_id; - appliedMessageIdsRef.current.add(latestMessage.id); - if (isCurrentUserAuthor) { void handleApplyWorkflow(latestMessage.code, latestMessage.id); } diff --git a/assets/test/collaborative-editor/components/AIAssistantPanel.test.tsx b/assets/test/collaborative-editor/components/AIAssistantPanel.test.tsx index 3111464a7c..7ba991b49f 100644 --- a/assets/test/collaborative-editor/components/AIAssistantPanel.test.tsx +++ b/assets/test/collaborative-editor/components/AIAssistantPanel.test.tsx @@ -777,4 +777,21 @@ describe('AIAssistantPanel', () => { expect(panel).toHaveClass('w-[400px]'); }); }); + + describe('Close button', () => { + it('is visible when onClose is provided', () => { + renderWithStore(); + + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + }); + + it('is absent when onClose is not provided', () => { + renderWithStore(); + + expect( + screen.queryByRole('button', { name: /close/i }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/assets/test/collaborative-editor/components/WorkflowEditor.test.tsx b/assets/test/collaborative-editor/components/WorkflowEditor.test.tsx index 0ff51ca016..f6225f8297 100644 --- a/assets/test/collaborative-editor/components/WorkflowEditor.test.tsx +++ b/assets/test/collaborative-editor/components/WorkflowEditor.test.tsx @@ -128,8 +128,10 @@ vi.mock('../../../js/react/lib/use-url-state', () => ({ })); // Mock session context hooks +const mockIsNewWorkflow = vi.fn(() => false); + vi.mock('../../../js/collaborative-editor/hooks/useSessionContext', () => ({ - useIsNewWorkflow: () => false, + useIsNewWorkflow: () => mockIsNewWorkflow(), useProjectRepoConnection: () => undefined, useProject: () => ({ id: 'project-1', @@ -186,6 +188,8 @@ const mockIsRunPanelOpen = vi.fn(() => false); const mockRunPanelContext = vi.fn(() => null); const mockOpenRunPanel = vi.fn(); const mockCloseRunPanel = vi.fn(); +const mockIsAIAssistantPanelOpen = vi.fn(() => false); +const mockShowLandingScreen = vi.fn(() => false); vi.mock('../../../js/collaborative-editor/hooks/useUI', () => ({ useIsRunPanelOpen: () => mockIsRunPanelOpen(), @@ -209,8 +213,8 @@ vi.mock('../../../js/collaborative-editor/hooks/useUI', () => ({ selectedTemplate: null, }), useIsCreateWorkflowPanelCollapsed: () => true, - useIsAIAssistantPanelOpen: () => false, - useShowLandingScreen: () => false, + useIsAIAssistantPanelOpen: () => mockIsAIAssistantPanelOpen(), + useShowLandingScreen: () => mockShowLandingScreen(), })); // Mock workflow hooks with controllable node selection @@ -231,6 +235,7 @@ const mockSelectNode = vi.fn((node: any) => { // Mock canRun state let mockCanRun = true; let mockTooltipMessage = ''; +let mockWorkflowStateOverride: Partial | null = null; vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({ useNodeSelection: () => ({ @@ -245,11 +250,14 @@ vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({ saveWorkflow: vi.fn(), }), useWorkflowState: (selector: any) => { + const workflow = mockWorkflowStateOverride + ? { ...mockWorkflow, ...mockWorkflowStateOverride } + : mockWorkflow; const state = { - workflow: mockWorkflow, - jobs: mockWorkflow.jobs, - triggers: mockWorkflow.triggers, - edges: mockWorkflow.edges, + workflow, + jobs: workflow.jobs, + triggers: workflow.triggers, + edges: workflow.edges, positions: {}, }; return typeof selector === 'function' ? selector(state) : state; @@ -277,6 +285,10 @@ describe('WorkflowEditor', () => { // Reset state mockIsRunPanelOpen.mockReturnValue(false); mockRunPanelContext.mockReturnValue(null); + mockIsNewWorkflow.mockReturnValue(false); + mockIsAIAssistantPanelOpen.mockReturnValue(false); + mockShowLandingScreen.mockReturnValue(false); + mockWorkflowStateOverride = null; currentNode = { type: null, node: null }; mockRunHandler.mockClear(); mockCanRun = true; @@ -466,4 +478,36 @@ describe('WorkflowEditor', () => { expect(inspector).toBeInTheDocument(); }); }); + + describe('Empty workflow placeholder', () => { + test('shows placeholder when isNewWorkflow, panel closed, and canvas is empty', async () => { + mockIsNewWorkflow.mockReturnValue(true); + mockShowLandingScreen.mockReturnValue(false); + mockIsAIAssistantPanelOpen.mockReturnValue(false); + mockWorkflowStateOverride = { jobs: [], triggers: [], edges: [] }; + + renderWorkflowEditor(); + + await waitFor(() => { + expect(screen.getByText('Create your workflow')).toBeInTheDocument(); + }); + }); + + test('suppresses placeholder when AI assistant panel is open', async () => { + mockIsNewWorkflow.mockReturnValue(true); + mockShowLandingScreen.mockReturnValue(false); + mockIsAIAssistantPanelOpen.mockReturnValue(true); + mockWorkflowStateOverride = { jobs: [], triggers: [], edges: [] }; + + renderWorkflowEditor(); + + await waitFor(() => { + expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); + }); + + expect( + screen.queryByText('Create your workflow') + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts index 8d4d28adce..f0ecbbb181 100644 --- a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts @@ -94,6 +94,9 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { const mockClearDiff = vi.fn(); const mockShowDiff = vi.fn(); + const mockSaveWorkflow = vi.fn(() => Promise.resolve()); + const mockOnValidationError = vi.fn(); + const mockWorkflowActions = { importWorkflow: mockImportWorkflow, startApplyingWorkflow: mockStartApplyingWorkflow, @@ -101,6 +104,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { startApplyingJobCode: mockStartApplyingJobCode, doneApplyingJobCode: mockDoneApplyingJobCode, updateJob: mockUpdateJob, + saveWorkflow: mockSaveWorkflow, }; const createMockMonacoRef = () => ({ @@ -149,6 +153,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -181,6 +186,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -214,6 +220,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -252,6 +259,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -285,6 +293,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -313,6 +322,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -345,6 +355,7 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -355,4 +366,338 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { expect(mockDoneApplyingWorkflow).not.toHaveBeenCalled(); }); }); + + it('auto-saves after importWorkflow when isNewWorkflow is true', async () => { + const { result } = renderHook(() => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession: null, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: { + ...mockWorkflowActions, + saveWorkflow: mockSaveWorkflow, + }, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: true, + appliedMessageIdsRef: { current: new Set() }, + }) + ); + + await result.current.handleApplyWorkflow('name: Test', 'msg-1'); + + await waitFor(() => { + expect(mockImportWorkflow).toHaveBeenCalled(); + expect(mockSaveWorkflow).toHaveBeenCalledWith({ silent: true }); + }); + + const importOrder = mockImportWorkflow.mock.invocationCallOrder[0]; + const saveOrder = mockSaveWorkflow.mock.invocationCallOrder[0]; + expect(importOrder).toBeLessThan(saveOrder); + }); + + it('does not auto-save when isNewWorkflow is false', async () => { + const { result } = renderHook(() => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession: null, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: { + ...mockWorkflowActions, + saveWorkflow: mockSaveWorkflow, + }, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, + appliedMessageIdsRef: { current: new Set() }, + }) + ); + + await result.current.handleApplyWorkflow('name: Test', 'msg-1'); + + await waitFor(() => { + expect(mockImportWorkflow).toHaveBeenCalled(); + }); + + expect(mockSaveWorkflow).not.toHaveBeenCalled(); + }); + + it('routes validation error to onValidationError callback when isNewWorkflow is true', async () => { + const { result } = renderHook(() => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession: null, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: mockWorkflowActions, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: true, + onValidationError: mockOnValidationError, + appliedMessageIdsRef: { current: new Set() }, + }) + ); + + await result.current.handleApplyWorkflow('invalid yaml', 'msg-1'); + + await waitFor(() => { + expect(mockOnValidationError).toHaveBeenCalledWith(expect.any(String)); + expect(notifications.alert).not.toHaveBeenCalled(); + }); + }); + + it('falls back to toast for validation errors when isNewWorkflow is false', async () => { + const { result } = renderHook(() => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession: null, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: mockWorkflowActions, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, + appliedMessageIdsRef: { current: new Set() }, + }) + ); + + await result.current.handleApplyWorkflow('invalid yaml', 'msg-1'); + + await waitFor(() => { + expect(notifications.alert).toHaveBeenCalledWith({ + title: 'Failed to apply workflow', + description: expect.any(String) as string, + }); + }); + }); + + it('skips auto-apply and marks message applied when appliedViaStreamingRef is set', async () => { + const appliedMessageIdsRef = { current: new Set() }; + const appliedViaStreamingRef = { current: false }; + + const userMessage = { + id: 'user-msg-1', + role: 'user' as const, + content: 'Build me a workflow', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:00Z', + user_id: 'user-123', + }; + + const assistantMessage = { + id: 'msg-1', + role: 'assistant' as const, + content: 'Here is your workflow', + code: 'name: Test', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:01Z', + }; + + type Props = { + currentSession: { messages: (typeof userMessage)[] } | null; + }; + + const { rerender } = renderHook( + ({ currentSession }: Props) => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: mockWorkflowActions, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: true, + appliedMessageIdsRef, + appliedViaStreamingRef, + }), + { initialProps: { currentSession: { messages: [userMessage] } } } + ); + + // Simulate streaming having already applied the YAML + appliedViaStreamingRef.current = true; + + rerender({ + currentSession: { messages: [userMessage, assistantMessage] }, + }); + + await waitFor(() => { + expect(mockImportWorkflow).not.toHaveBeenCalled(); + expect(mockSaveWorkflow).not.toHaveBeenCalled(); + expect(appliedMessageIdsRef.current.has('msg-1')).toBe(true); + expect(appliedViaStreamingRef.current).toBe(false); + }); + }); + + it('resets appliedViaStreamingRef during session-load so subsequent responses are not skipped', async () => { + const appliedMessageIdsRef = { current: new Set() }; + const appliedViaStreamingRef = { current: false }; + + const userMessage1 = { + id: 'user-msg-1', + role: 'user' as const, + content: 'Build me a workflow', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:00Z', + user_id: 'user-123', + }; + const assistantMessage1 = { + id: 'msg-1', + role: 'assistant' as const, + content: 'Here is workflow 1', + code: 'name: Test 1', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:01Z', + }; + const userMessage2 = { + id: 'user-msg-2', + role: 'user' as const, + content: 'Refine the workflow', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:02Z', + user_id: 'user-123', + }; + const assistantMessage2 = { + id: 'msg-2', + role: 'assistant' as const, + content: 'Here is workflow 2', + code: 'name: Test 2', + status: 'success' as const, + inserted_at: '2024-01-01T00:00:03Z', + }; + + type Props = { + currentSession: { messages: (typeof userMessage1)[] } | null; + }; + + const { rerender } = renderHook( + ({ currentSession }: Props) => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: mockWorkflowActions, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: true, + appliedMessageIdsRef, + appliedViaStreamingRef, + }), + { initialProps: { currentSession: null } } + ); + + // Simulate streaming having fired before the first new_message arrives + appliedViaStreamingRef.current = true; + + // First new_message: session loading for the first time (hasLoadedSessionRef = false) + rerender({ + currentSession: { messages: [userMessage1, assistantMessage1] }, + }); + + await waitFor(() => { + // Session-load path marks messages applied but does not re-import + expect(mockImportWorkflow).not.toHaveBeenCalled(); + // Fix: ref must be cleared so the next real response isn't skipped + expect(appliedViaStreamingRef.current).toBe(false); + }); + + // Second response arrives — ref is now false, so auto-apply proceeds + rerender({ + currentSession: { + messages: [ + userMessage1, + assistantMessage1, + userMessage2, + assistantMessage2, + ], + }, + }); + + await waitFor(() => { + expect(mockImportWorkflow).toHaveBeenCalledTimes(1); + }); + }); + + it('shows save-failure toast and does not call onValidationError when save rejects', async () => { + const failingSaveWorkflow = vi.fn(() => + Promise.reject(new Error('Network error')) + ); + + const { result } = renderHook(() => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession: null, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: { + ...mockWorkflowActions, + saveWorkflow: failingSaveWorkflow, + }, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: true, + onValidationError: mockOnValidationError, + appliedMessageIdsRef: { current: new Set() }, + }) + ); + + await result.current.handleApplyWorkflow('name: Test', 'msg-1'); + + await waitFor(() => { + expect(mockImportWorkflow).toHaveBeenCalled(); + expect(notifications.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Failed to save workflow', + description: 'Network error', + action: expect.objectContaining({ label: 'Retry' }) as object, + }) + ); + expect(mockOnValidationError).not.toHaveBeenCalled(); + }); + }); });