From 0f88abb03e8d6ea06e786305ee598de03c3138ae Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Thu, 25 Jun 2026 22:08:35 +0100 Subject: [PATCH 01/18] =?UTF-8?q?feat(LandingScreen):=20wire=20Build=20wit?= =?UTF-8?q?h=20AI=20path=20=E2=80=94=20dismiss=20screen,=20open=20AI=20pan?= =?UTF-8?q?el,=20suppress=20legacy=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/collaborative-editor/CollaborativeEditor.tsx | 9 ++++++--- .../collaborative-editor/components/WorkflowEditor.tsx | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) 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/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 && (
From f4738a9f58006bd99573da13d458b90a289d021f Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Thu, 25 Jun 2026 22:14:48 +0100 Subject: [PATCH 02/18] =?UTF-8?q?feat(AIAssistantPanel):=20lock=20panel=20?= =?UTF-8?q?close=20while=20isNewWorkflow=20=E2=80=94=20hide=20close=20butt?= =?UTF-8?q?on,=20disable=20=E2=8C=98K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AIAssistantPanel.tsx | 46 ++++++++++--------- .../components/AIAssistantPanelWrapper.tsx | 10 ++-- 2 files changed, 30 insertions(+), 26 deletions(-) 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..b547fcd8ae 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx @@ -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; @@ -683,7 +683,7 @@ export function AIAssistantPanelWrapper({
Date: Thu, 25 Jun 2026 22:32:29 +0100 Subject: [PATCH 03/18] feat(useAIWorkflowApplications): route validation errors to chat on new workflows When YAML validation fails (syntax, ID format, schema) during handleApplyWorkflow and the workflow is new, inject the error as an assistant message into the AI chat thread instead of showing a toast. Save failures keep the existing toast behaviour. --- .../components/AIAssistantPanelWrapper.tsx | 20 +++++++++- .../hooks/useAIWorkflowApplications.ts | 40 ++++++++++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx index b547fcd8ae..83dc10fe9b 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, @@ -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,20 @@ 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] + ); + // Hook to handle workflow/job code application logic const { handleApplyWorkflow, handlePreviewJobCode, handleApplyJobCode } = useAIWorkflowApplications({ @@ -573,6 +588,8 @@ export function AIAssistantPanelWrapper({ : null, currentUserId: user?.id, aiMode, + isNewWorkflow, + onValidationError, workflowActions: { importWorkflow, startApplyingWorkflow, @@ -580,6 +597,7 @@ export function AIAssistantPanelWrapper({ startApplyingJobCode, doneApplyingJobCode, updateJob, + saveWorkflow, }, monacoRef, jobs, diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index 729775704e..63975a1208 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, @@ -122,6 +124,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 +133,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[]; @@ -146,6 +151,7 @@ export function useAIWorkflowApplications({ startApplyingJobCode, doneApplyingJobCode, updateJob, + saveWorkflow, } = workflowActions; /** @@ -199,14 +205,35 @@ export function useAIWorkflowApplications({ ); await importWorkflow(workflowStateWithCreds); + + if (isNewWorkflow) { + try { + await saveWorkflow({ silent: true }); + } catch (saveError) { + 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', + }); + } + } } 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 @@ -224,6 +251,9 @@ export function useAIWorkflowApplications({ doneApplyingWorkflow, jobs, setApplyingMessageId, + isNewWorkflow, + onValidationError, + saveWorkflow, ] ); From f4c031dbe52c237b27f2eac8328866b9d6dc7081 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Thu, 25 Jun 2026 22:53:46 +0100 Subject: [PATCH 04/18] test(build-with-ai): add coverage for new-workflow AI path behaviours Add tests for the six untested behaviours introduced in #4868: - auto-save called after importWorkflow when isNewWorkflow - validation errors routed to onValidationError callback, not toast - save failures show toast and do not invoke onValidationError - close button absent when onClose is undefined - empty-canvas placeholder suppressed when AI panel is open Also adds saveWorkflow to mockWorkflowActions and isNewWorkflow: false to all existing handleApplyWorkflow renderHook calls to match the updated hook interface. --- .../components/AIAssistantPanel.test.tsx | 17 ++ .../components/WorkflowEditor.test.tsx | 58 +++++- ...useAIWorkflowApplications.workflow.test.ts | 182 ++++++++++++++++++ 3 files changed, 250 insertions(+), 7 deletions(-) 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..5d224e0f67 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,175 @@ 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('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({ + title: 'Failed to save workflow', + description: 'Network error', + }); + expect(mockOnValidationError).not.toHaveBeenCalled(); + }); + }); }); From 08711ca06bfd1517b736d09df2d417febf6f729c Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Thu, 25 Jun 2026 23:03:48 +0100 Subject: [PATCH 05/18] fix(handleApplyWorkflow): skip fit-view and add Retry button when save fails When saveWorkflow rejects on a new workflow, fit-view was incorrectly dispatched after doneApplyingWorkflow, implying the workflow was successfully persisted when it wasn't. Add a saveSucceeded flag and skip fit-view when save fails. Also wires up the Retry action on the save-failure toast so the user can re-attempt the full apply+save without losing their canvas state. --- .../hooks/useAIWorkflowApplications.ts | 12 +++++++++++- .../hooks/useAIWorkflowApplications.workflow.test.ts | 11 +++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index 63975a1208..b514944fb8 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -191,6 +191,7 @@ export function useAIWorkflowApplications({ // Returns false if coordination failed (other users won't be notified) const coordinated = await startApplyingWorkflow(messageId); + let saveSucceeded = true; try { const workflowSpec = parseWorkflowYAML(yaml); @@ -210,6 +211,7 @@ export function useAIWorkflowApplications({ 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', @@ -217,6 +219,10 @@ export function useAIWorkflowApplications({ saveError instanceof Error ? saveError.message : 'Unknown error occurred', + action: { + label: 'Retry', + onClick: () => void handleApplyWorkflow(yaml, messageId), + }, }); } } @@ -240,7 +246,11 @@ export function useAIWorkflowApplications({ // (otherwise other users weren't notified of the start) if (coordinated) { await doneApplyingWorkflow(messageId); - flowEvents.dispatch('fit-view'); + // Only fit-view on full success — skip if save failed so the canvas + // doesn't zoom in on an unpersisted workflow + if (saveSucceeded !== false) { + flowEvents.dispatch('fit-view'); + } } } }, diff --git a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts index 5d224e0f67..3fd474a0ed 100644 --- a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts @@ -530,10 +530,13 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { await waitFor(() => { expect(mockImportWorkflow).toHaveBeenCalled(); - expect(notifications.alert).toHaveBeenCalledWith({ - title: 'Failed to save workflow', - description: 'Network error', - }); + 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(); }); }); From 555ab9c138f763b40cecd490a307e5dde4c18b20 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Mon, 29 Jun 2026 15:18:07 +0100 Subject: [PATCH 06/18] fix(streaming-dedup): prevent unsaved changes indicator after AI applies workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow was being applied twice — once via streaming and again when the final new_message arrived — causing a transient dirty state (unsaved red dot) between the second import and save. Consolidate the deduplication flag into useAIWorkflowApplications so the "mark as applied + skip" logic happens atomically in one place rather than split across two separate useEffect passes in the wrapper. Test changes: delete a duplicate test identical to an existing case, tighten the streaming-skip assertion to also verify saveWorkflow is not called. --- .../components/AIAssistantPanelWrapper.tsx | 25 ++------ .../hooks/useAIWorkflowApplications.ts | 15 ++++- ...useAIWorkflowApplications.workflow.test.ts | 64 +++++++++++++++++++ 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx index 83dc10fe9b..2b0397e5fc 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx @@ -574,6 +574,10 @@ export function AIAssistantPanelWrapper({ [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({ @@ -607,6 +611,7 @@ export function AIAssistantPanelWrapper({ previewingMessageId, setApplyingMessageId, appliedMessageIdsRef, + appliedViaStreamingRef, }); // Auto-preview job code when AI responds with code @@ -626,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 @@ -644,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__'); } } @@ -656,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 (
void; appliedMessageIdsRef: React.MutableRefObject>; + appliedViaStreamingRef?: React.MutableRefObject; }) { const { importWorkflow, @@ -194,7 +196,6 @@ export function useAIWorkflowApplications({ let saveSucceeded = true; try { const workflowSpec = parseWorkflowYAML(yaml); - validateIds(workflowSpec); // IDs are already in the YAML from AI (sent with IDs, like legacy editor) @@ -455,6 +456,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( @@ -473,8 +484,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/hooks/useAIWorkflowApplications.workflow.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts index 3fd474a0ed..8f1c1829fd 100644 --- a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts @@ -497,6 +497,70 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { }); }); + 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('shows save-failure toast and does not call onValidationError when save rejects', async () => { const failingSaveWorkflow = vi.fn(() => Promise.reject(new Error('Network error')) From d085c225e47ce13a56e08989127a89f928209641 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Mon, 29 Jun 2026 17:20:39 +0100 Subject: [PATCH 07/18] feat(MessageList): show validation errors inline, generic errors with Retry banner --- .../components/MessageList.tsx | 96 +++++++++++-------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/assets/js/collaborative-editor/components/MessageList.tsx b/assets/js/collaborative-editor/components/MessageList.tsx index 48e75a9fca..ecd7f69965 100644 --- a/assets/js/collaborative-editor/components/MessageList.tsx +++ b/assets/js/collaborative-editor/components/MessageList.tsx @@ -545,18 +545,34 @@ export function MessageList({ } >
- + {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' && (
From 0c29047c067e4281271503bfe677d88c73ad2d42 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Mon, 29 Jun 2026 17:21:11 +0100 Subject: [PATCH 08/18] fix(useAIWorkflowApplications): reset streaming ref on session load; fix stale Retry closure --- .../hooks/useAIWorkflowApplications.ts | 30 ++++-- ...useAIWorkflowApplications.workflow.test.ts | 96 +++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index 417c241958..c31ee1fdc9 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -169,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 * @@ -193,6 +199,9 @@ 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); @@ -207,6 +216,7 @@ export function useAIWorkflowApplications({ ); await importWorkflow(workflowStateWithCreds); + applySucceeded = true; if (isNewWorkflow) { try { @@ -222,7 +232,8 @@ export function useAIWorkflowApplications({ : 'Unknown error occurred', action: { label: 'Retry', - onClick: () => void handleApplyWorkflow(yaml, messageId), + onClick: () => + void handleApplyWorkflowRef.current?.(yaml, messageId), }, }); } @@ -243,13 +254,14 @@ export function useAIWorkflowApplications({ } } 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); - // Only fit-view on full success — skip if save failed so the canvas - // doesn't zoom in on an unpersisted workflow - if (saveSucceeded !== false) { + // 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'); } } @@ -268,6 +280,9 @@ export function useAIWorkflowApplications({ ] ); + // 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 * @@ -447,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; } diff --git a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts index 8f1c1829fd..f0ecbbb181 100644 --- a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts @@ -561,6 +561,102 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { }); }); + 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')) From d86f053bb7b5113c6c80b4778a8e7fd3bca753c1 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 10:34:20 +0100 Subject: [PATCH 09/18] fix(new-workflow): block URL params from corrupting landing screen state On /new, URL params like ?chat=true, ?method=template, ?panel=run, ?panel=editor, and ?panel=settings could bypass the landing screen or open panels that shouldn't be reachable before the user takes an action. - createUIStore: return clean defaults when isNewWorkflow=true so the store ignores all URL params at init time - WorkflowEditor: guard the URL->panel sync effect with isNewWorkflow so ?panel=run can't open the run panel - WorkflowEditor: short-circuit isIDEOpen and showInspector on new workflows so FullScreenIDE (z-50) and Inspector can't render above the landing screen via URL params - Add TODO-AI-FIRST annotations on the method URL sync and old placeholder block for cleanup when the left-panel flow is removed --- .../components/WorkflowEditor.tsx | 24 ++++++++++++++----- .../stores/createUIStore.ts | 12 ++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/assets/js/collaborative-editor/components/WorkflowEditor.tsx b/assets/js/collaborative-editor/components/WorkflowEditor.tsx index 730a980189..6a8df8c60c 100644 --- a/assets/js/collaborative-editor/components/WorkflowEditor.tsx +++ b/assets/js/collaborative-editor/components/WorkflowEditor.tsx @@ -133,6 +133,10 @@ export function WorkflowEditor({ }, [isRunPanelOpen, runPanelContext, params, updateSearchParams]); useEffect(() => { + // On /new, URL params can't drive panel state — the landing screen is the + // only valid entry point and the canvas/panels shouldn't be reachable. + if (isNewWorkflow) return; + const panelParam = params['panel'] ?? null; if (panelParam === 'run' && !isRunPanelOpen) { @@ -187,6 +191,7 @@ export function WorkflowEditor({ }, 0); } }, [ + isNewWorkflow, params, isRunPanelOpen, currentNode.type, @@ -365,6 +370,8 @@ export function WorkflowEditor({ clearCanvas, ]); + // TODO-AI-FIRST (#4856): remove this entire method URL sync once the left-panel + // create flow (template/import/ai) is deleted — ?method= will no longer exist. // Sync method to URL (similar to AI panel's chat param sync) const isSyncingMethodRef = useRef(false); useEffect(() => { @@ -379,7 +386,7 @@ export function WorkflowEditor({ }, 0); }, [isCreateWorkflowPanelCollapsed, leftPanelMethod, updateSearchParams]); - const isIDEOpen = params['panel'] === 'editor'; + const isIDEOpen = !isNewWorkflow && params['panel'] === 'editor'; const selectedJobId = params['job'] ?? null; const handleCloseIDE = useCallback(() => { @@ -390,11 +397,14 @@ export function WorkflowEditor({ selectNode(null); }; + // On /new, no nodes exist yet and the landing screen is the only valid UI. + // Block Inspector and IDE so URL params like ?panel=settings can't open them. const showInspector = - params['panel'] === 'settings' || - params['panel'] === 'code' || - params['panel'] === 'publish-template' || - Boolean(currentNode.node); + !isNewWorkflow && + (params['panel'] === 'settings' || + params['panel'] === 'code' || + params['panel'] === 'publish-template' || + Boolean(currentNode.node)); const handleMethodChange = (method: 'template' | 'import' | 'ai' | null) => { // Always clear template URL params when switching methods - start fresh each time @@ -501,7 +511,9 @@ export function WorkflowEditor({
- {/* TODO-AI-FIRST: remove this placeholder once the landing screen (#4856) always covers the new-workflow state */} + {/* TODO-AI-FIRST (#4856): delete this entire placeholder block once the left-panel + create flow is removed — the landing screen always covers the new-workflow state + and this fallback UI (with its old Browse/AI/scratch buttons) should never appear */} {/* Show placeholder when workflow is empty and landing screen is not covering it */} {isNewWorkflow && !showLandingScreen && diff --git a/assets/js/collaborative-editor/stores/createUIStore.ts b/assets/js/collaborative-editor/stores/createUIStore.ts index d569f06a13..ae44dbcef0 100644 --- a/assets/js/collaborative-editor/stores/createUIStore.ts +++ b/assets/js/collaborative-editor/stores/createUIStore.ts @@ -80,6 +80,18 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { aiAssistantPanelOpen: boolean; createWorkflowPanelCollapsed: boolean; } => { + // TODO-AI-FIRST (#4856): once the left-panel create flow is fully removed, + // delete the method/hasMethod branch below and simplify to just the chat param. + + // On /new the landing screen is the only valid entry point — ignore all URL + // params so ?chat=true or ?method=... can't bypass or corrupt it. + if (isNewWorkflow) { + return { + aiAssistantPanelOpen: false, + createWorkflowPanelCollapsed: true, + }; + } + try { const params = new URLSearchParams(window.location.search); const chatOpen = params.get('chat') === 'true'; From 8c28f9d4f78ea5e9e610e0e9fbc8ab2ab189f7e7 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 10:34:36 +0100 Subject: [PATCH 10/18] fix(useAIWorkflowApplications): retry save failure without re-applying YAML When the initial save fails after AI applies a workflow, the Retry button was re-running the full handleApplyWorkflow (importWorkflow + save). Add a saveWorkflowRef that points at the latest saveWorkflow callback so Retry only calls save, skipping the already-successful canvas apply. Also set duration: Infinity on the save-failure toast so it persists until the user explicitly retries or navigates away. --- .../hooks/useAIWorkflowApplications.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index c31ee1fdc9..dcc451d42e 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -175,6 +175,11 @@ export function useAIWorkflowApplications({ ((yaml: string, messageId: string) => Promise) | null >(null); + // Stable ref for save-only retries (importWorkflow already succeeded) + const saveWorkflowRef = useRef< + ((opts?: { silent?: boolean }) => Promise) | null + >(null); + /** * Apply workflow YAML to the canvas * @@ -230,10 +235,10 @@ export function useAIWorkflowApplications({ saveError instanceof Error ? saveError.message : 'Unknown error occurred', + duration: Infinity, // toast only dismisses by clicking 'x' so the user definitely sees the error action: { label: 'Retry', - onClick: () => - void handleApplyWorkflowRef.current?.(yaml, messageId), + onClick: () => void saveWorkflowRef.current?.({ silent: true }), }, }); } @@ -280,8 +285,9 @@ export function useAIWorkflowApplications({ ] ); - // Keep ref pointing at the latest callback so the Retry toast closure never goes stale + // Keep refs pointing at the latest callbacks so Retry toast closures never go stale handleApplyWorkflowRef.current = handleApplyWorkflow; + saveWorkflowRef.current = saveWorkflow; /** * Preview job code diff in Monaco editor From f08d3477f90efa16665e9fe4bce5105f1a8e6ccd Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 10:34:59 +0100 Subject: [PATCH 11/18] test(MessageList): update error rendering assertions for non-empty content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error rendering for assistant messages changed: non-empty error content now renders inline in a styled red box (ai-validation-error) rather than showing the hardcoded 'Failed to send message' banner. Split the single test into two cases — one for non-empty content (styled box) and one for empty content (banner). --- .../components/MessageList.test.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/assets/test/collaborative-editor/components/MessageList.test.tsx b/assets/test/collaborative-editor/components/MessageList.test.tsx index 2536d468fb..719b712b2e 100644 --- a/assets/test/collaborative-editor/components/MessageList.test.tsx +++ b/assets/test/collaborative-editor/components/MessageList.test.tsx @@ -196,11 +196,29 @@ describe('MessageList', () => { }); describe('Message Status', () => { - it('should show error state for failed messages', () => { + it('should show error content in styled box for assistant error with content', () => { const messages = [ createMockAIMessage({ role: 'assistant', - content: 'Failed message', + content: 'YAML parse failed: unexpected token', + status: 'error', + }), + ]; + + render(); + + // Non-empty content renders inline in a red validation error box + expect(screen.getByTestId('ai-validation-error')).toBeInTheDocument(); + expect( + screen.getByText('YAML parse failed: unexpected token') + ).toBeInTheDocument(); + }); + + it('should show "Failed to send message" banner for assistant error with no content', () => { + const messages = [ + createMockAIMessage({ + role: 'assistant', + content: '', status: 'error', }), ]; From 747078c8c2dd2f56f6d95e84808acf82ffd15c74 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 11:00:50 +0100 Subject: [PATCH 12/18] fix(tests): add saveWorkflow and isNewWorkflow to useAIWorkflowApplications test fixtures Both test files were missing the now-required saveWorkflow field in mockWorkflowActions and the required isNewWorkflow boolean at every renderHook call site, causing TypeScript compile errors. --- .../useAIWorkflowApplications.autoApply.test.ts | 9 +++++++++ .../useAIWorkflowApplications.jobCode.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.autoApply.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.autoApply.test.ts index e385b16333..2220f2d08d 100644 --- a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.autoApply.test.ts +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.autoApply.test.ts @@ -79,6 +79,8 @@ describe('useAIWorkflowApplications - Auto-Application', () => { const mockClearDiff = vi.fn(); const mockShowDiff = vi.fn(); + const mockSaveWorkflow = vi.fn(() => Promise.resolve()); + const mockWorkflowActions = { importWorkflow: mockImportWorkflow, startApplyingWorkflow: mockStartApplyingWorkflow, @@ -86,6 +88,7 @@ describe('useAIWorkflowApplications - Auto-Application', () => { startApplyingJobCode: mockStartApplyingJobCode, doneApplyingJobCode: mockDoneApplyingJobCode, updateJob: mockUpdateJob, + saveWorkflow: mockSaveWorkflow, }; const createMockMonacoRef = () => ({ @@ -168,6 +171,7 @@ describe('useAIWorkflowApplications - Auto-Application', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef, }), { initialProps: { currentSession: null } } @@ -238,6 +242,7 @@ describe('useAIWorkflowApplications - Auto-Application', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -286,6 +291,7 @@ describe('useAIWorkflowApplications - Auto-Application', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef, }), { initialProps: { currentSession: null } } @@ -330,6 +336,7 @@ describe('useAIWorkflowApplications - Auto-Application', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -368,6 +375,7 @@ describe('useAIWorkflowApplications - Auto-Application', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -423,6 +431,7 @@ describe('useAIWorkflowApplications - Auto-Application', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef, }), { initialProps: { currentSession: null } } diff --git a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.jobCode.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.jobCode.test.ts index 0145255e5d..ae8bbabe25 100644 --- a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.jobCode.test.ts +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.jobCode.test.ts @@ -39,6 +39,8 @@ describe('useAIWorkflowApplications - Job Code', () => { const mockClearDiff = vi.fn(); const mockShowDiff = vi.fn(); + const mockSaveWorkflow = vi.fn(() => Promise.resolve()); + const mockWorkflowActions = { importWorkflow: mockImportWorkflow, startApplyingWorkflow: mockStartApplyingWorkflow, @@ -46,6 +48,7 @@ describe('useAIWorkflowApplications - Job Code', () => { startApplyingJobCode: mockStartApplyingJobCode, doneApplyingJobCode: mockDoneApplyingJobCode, updateJob: mockUpdateJob, + saveWorkflow: mockSaveWorkflow, }; const createMockMonacoRef = () => ({ @@ -97,6 +100,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -125,6 +129,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -153,6 +158,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: 'msg-1', // Already previewing setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -182,6 +188,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: 'msg-1', // Existing preview setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -208,6 +215,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -238,6 +246,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -268,6 +277,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -301,6 +311,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -328,6 +339,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: 'msg-1', setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -356,6 +368,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -384,6 +397,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); @@ -416,6 +430,7 @@ describe('useAIWorkflowApplications - Job Code', () => { setPreviewingMessageId: mockSetPreviewingMessageId, previewingMessageId: null, setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: false, appliedMessageIdsRef: { current: new Set() }, }) ); From b77e0fc810020f1e23bd5cf5322dac56f6e64cff Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 11:04:47 +0100 Subject: [PATCH 13/18] fix(useAIWorkflowApplications): reset appliedViaStreamingRef on apply failure When streaming fires mid-conversation and handleApplyWorkflow fails (e.g. invalid YAML, importWorkflow throws), appliedViaStreamingRef was left as true. The session-load guard only runs once per session, so the next real new_message would hit the early-return and the workflow would silently never be applied. Reset the ref in the catch block so a failed streaming apply doesn't block the subsequent settled-message auto-apply. --- .../collaborative-editor/hooks/useAIWorkflowApplications.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index dcc451d42e..70542ad00c 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -246,6 +246,10 @@ export function useAIWorkflowApplications({ } catch (error) { console.error('[AI Assistant] Failed to apply workflow:', error); + // If streaming set this ref before the apply failed, clear it so the + // next real new_message isn't silently skipped by the auto-apply guard. + if (appliedViaStreamingRef) appliedViaStreamingRef.current = false; + const errorMessage = error instanceof Error ? error.message : 'Invalid workflow YAML'; From 31f9899cb112114257bc90dd06a3112b4ca46a1a Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 11:24:34 +0100 Subject: [PATCH 14/18] fix(useAIWorkflowApplications): handle retry save failure with error toast The save-failure Retry onClick used void to discard the promise, so a second save failure was silently swallowed with no user feedback. Chain a .catch on the retry promise to show another alert if the retry also fails. --- .../hooks/useAIWorkflowApplications.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index 70542ad00c..3991181d65 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -238,7 +238,22 @@ export function useAIWorkflowApplications({ duration: Infinity, // toast only dismisses by clicking 'x' so the user definitely sees the error action: { label: 'Retry', - onClick: () => void saveWorkflowRef.current?.({ silent: true }), + onClick: () => + void saveWorkflowRef + .current?.({ silent: true }) + ?.catch((retryError: unknown) => { + console.error( + '[AI Assistant] Retry save failed:', + retryError + ); + notifications.alert({ + title: 'Failed to save workflow', + description: + retryError instanceof Error + ? retryError.message + : 'Unknown error occurred', + }); + }), }, }); } From 8916cd36910b9a79961a1266005c1accca843d0e Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 11:45:21 +0100 Subject: [PATCH 15/18] fix(useShowLandingScreen): gate on reactive isNewWorkflow to fix slow channel join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createUIStore initialises once inside useState — if the channel hasn't joined by mount time, isNewWorkflow is false and showLandingScreen was permanently frozen as false. Initialize showLandingScreen to true and move the isNewWorkflow gate into useShowLandingScreen, where it reads from the reactive SessionContextStore. On slow connections the landing screen now correctly appears once the channel joins and delivers isNewWorkflow=true. --- assets/js/collaborative-editor/hooks/useUI.ts | 9 +++++++- .../stores/createUIStore.ts | 2 +- .../createUIStore.landing-screen.test.ts | 22 +++++++------------ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/assets/js/collaborative-editor/hooks/useUI.ts b/assets/js/collaborative-editor/hooks/useUI.ts index 1eb759186e..4974e7b403 100644 --- a/assets/js/collaborative-editor/hooks/useUI.ts +++ b/assets/js/collaborative-editor/hooks/useUI.ts @@ -11,6 +11,8 @@ import { StoreContext } from '../contexts/StoreProvider'; import type { UIStoreInstance } from '../stores/createUIStore'; import type { UIState } from '../types/ui'; +import { useIsNewWorkflow } from './useSessionContext'; + /** * Main hook for accessing the UIStore instance * Handles context access and error handling once @@ -139,11 +141,16 @@ export const useIsCreateWorkflowPanelCollapsed = (): boolean => { * Hook to check if the landing screen overlay is visible */ export const useShowLandingScreen = (): boolean => { + const isNewWorkflow = useIsNewWorkflow(); const uiStore = useUIStore(); const selectShowLandingScreen = uiStore.withSelector( state => state.showLandingScreen ); - return useSyncExternalStore(uiStore.subscribe, selectShowLandingScreen); + const showLandingScreen = useSyncExternalStore( + uiStore.subscribe, + selectShowLandingScreen + ); + return isNewWorkflow && showLandingScreen; }; /** diff --git a/assets/js/collaborative-editor/stores/createUIStore.ts b/assets/js/collaborative-editor/stores/createUIStore.ts index ae44dbcef0..8db26c4aea 100644 --- a/assets/js/collaborative-editor/stores/createUIStore.ts +++ b/assets/js/collaborative-editor/stores/createUIStore.ts @@ -130,7 +130,7 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { aiAssistantPanelOpen, aiAssistantInitialMessage: null, createWorkflowPanelCollapsed, - showLandingScreen: isNewWorkflow, + showLandingScreen: true, showYAMLImportModal: false, templatePanel: { templates: [], diff --git a/assets/test/collaborative-editor/stores/createUIStore.landing-screen.test.ts b/assets/test/collaborative-editor/stores/createUIStore.landing-screen.test.ts index 967ce0cae5..599716df13 100644 --- a/assets/test/collaborative-editor/stores/createUIStore.landing-screen.test.ts +++ b/assets/test/collaborative-editor/stores/createUIStore.landing-screen.test.ts @@ -3,6 +3,9 @@ * * Tests the showLandingScreen initial state and dismissLandingScreen command * in isolation against the real createUIStore implementation. + * + * Note: showLandingScreen starts true in the store; the useShowLandingScreen + * hook gates visibility by also checking isNewWorkflow from SessionContextStore. */ import { describe, expect, test } from 'vitest'; @@ -14,19 +17,9 @@ import { createUIStore } from '../../../js/collaborative-editor/stores/createUIS // ============================================================================= describe('createUIStore — landing screen initial state', () => { - test('createUIStore(true) — showLandingScreen starts as true', () => { - const store = createUIStore(true); - expect(store.getSnapshot().showLandingScreen).toBe(true); - }); - - test('createUIStore(false) — showLandingScreen starts as false', () => { - const store = createUIStore(false); - expect(store.getSnapshot().showLandingScreen).toBe(false); - }); - - test('createUIStore() with no argument — showLandingScreen starts as false', () => { + test('showLandingScreen starts as true', () => { const store = createUIStore(); - expect(store.getSnapshot().showLandingScreen).toBe(false); + expect(store.getSnapshot().showLandingScreen).toBe(true); }); }); @@ -36,7 +29,7 @@ describe('createUIStore — landing screen initial state', () => { describe('createUIStore — dismissLandingScreen', () => { test('calling dismissLandingScreen() sets showLandingScreen to false', () => { - const store = createUIStore(true); + const store = createUIStore(); expect(store.getSnapshot().showLandingScreen).toBe(true); store.dismissLandingScreen(); @@ -45,7 +38,8 @@ describe('createUIStore — dismissLandingScreen', () => { }); test('calling dismissLandingScreen() on an already-dismissed store is a no-op', () => { - const store = createUIStore(false); + const store = createUIStore(); + store.dismissLandingScreen(); store.dismissLandingScreen(); expect(store.getSnapshot().showLandingScreen).toBe(false); }); From d133168717208f938c8208a59222bc439433e7b0 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 11:52:49 +0100 Subject: [PATCH 16/18] fix(useAIWorkflowApplications): handle null return from saveWorkflow on disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveWorkflow returns null (not throws) when the WebSocket is disconnected. The null return was silently treated as success — saveSucceeded stayed true, fit-view fired, and the landing screen stayed dismissed. Check the return value and throw on null so the existing catch path handles it: shows the save-failure toast with a Retry button and sets saveSucceeded to false. The retry path is also fixed to handle null via an async IIFE. --- .../hooks/useAIWorkflowApplications.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index 3991181d65..6031d5424b 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -225,7 +225,12 @@ export function useAIWorkflowApplications({ if (isNewWorkflow) { try { - await saveWorkflow({ silent: true }); + const saved = await saveWorkflow({ silent: true }); + if (!saved) { + throw new Error( + 'Your connection was lost. Reconnect and try again.' + ); + } } catch (saveError) { saveSucceeded = false; console.error('[AI Assistant] Failed to save workflow:', saveError); @@ -238,10 +243,17 @@ export function useAIWorkflowApplications({ duration: Infinity, // toast only dismisses by clicking 'x' so the user definitely sees the error action: { label: 'Retry', - onClick: () => - void saveWorkflowRef - .current?.({ silent: true }) - ?.catch((retryError: unknown) => { + onClick: () => { + void (async () => { + try { + const saved = await saveWorkflowRef.current?.({ + silent: true, + }); + if (!saved) + throw new Error( + 'Your connection was lost. Reconnect and try again.' + ); + } catch (retryError: unknown) { console.error( '[AI Assistant] Retry save failed:', retryError @@ -253,7 +265,9 @@ export function useAIWorkflowApplications({ ? retryError.message : 'Unknown error occurred', }); - }), + } + })(); + }, }, }); } From a4dadc43adcd0d4aca3cfccecf30b7f612c86d0a Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 11:55:12 +0100 Subject: [PATCH 17/18] fix(useAIWorkflowApplications): reset appliedViaStreamingRef on save failure after streaming apply When importWorkflow succeeded but saveWorkflow subsequently failed, the inner catch set saveSucceeded=false but never reset appliedViaStreamingRef. The outer catch (which does reset it) was never reached, leaving the ref true. The next confirmed message from the server was then silently skipped by the auto-apply effect. Reset the ref in the inner save-failure catch, mirroring the existing reset in the outer catch. Covers both thrown errors and null returns (disconnect). --- .../hooks/useAIWorkflowApplications.ts | 1 + ...useAIWorkflowApplications.workflow.test.ts | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index 6031d5424b..cb30761198 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -233,6 +233,7 @@ export function useAIWorkflowApplications({ } } catch (saveError) { saveSucceeded = false; + if (appliedViaStreamingRef) appliedViaStreamingRef.current = false; console.error('[AI Assistant] Failed to save workflow:', saveError); notifications.alert({ title: 'Failed to save workflow', diff --git a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts index f0ecbbb181..f57a201538 100644 --- a/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts +++ b/assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts @@ -700,4 +700,78 @@ describe('useAIWorkflowApplications - handleApplyWorkflow', () => { expect(mockOnValidationError).not.toHaveBeenCalled(); }); }); + + it('resets appliedViaStreamingRef when save rejects after a successful streaming apply', async () => { + const appliedViaStreamingRef = { current: true }; + 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, + appliedMessageIdsRef: { current: new Set() }, + appliedViaStreamingRef, + }) + ); + + await result.current.handleApplyWorkflow('name: Test', 'msg-1'); + + await waitFor(() => { + expect(mockImportWorkflow).toHaveBeenCalled(); + expect(appliedViaStreamingRef.current).toBe(false); + }); + }); + + it('resets appliedViaStreamingRef when save returns null (disconnected) after a successful streaming apply', async () => { + const appliedViaStreamingRef = { current: true }; + const disconnectedSaveWorkflow = vi.fn(() => Promise.resolve(null)); + + const { result } = renderHook(() => + useAIWorkflowApplications({ + sessionId: 'session-1', + page: 'workflow_template', + currentSession: null, + currentUserId: 'user-123', + aiMode: createMockAIMode('workflow_template'), + workflowActions: { + ...mockWorkflowActions, + saveWorkflow: disconnectedSaveWorkflow, + }, + monacoRef: createMockMonacoRef(), + jobs: [], + canApplyChanges: true, + connectionState: 'connected' as ConnectionState, + setPreviewingMessageId: mockSetPreviewingMessageId, + previewingMessageId: null, + setApplyingMessageId: mockSetApplyingMessageId, + isNewWorkflow: true, + appliedMessageIdsRef: { current: new Set() }, + appliedViaStreamingRef, + }) + ); + + await result.current.handleApplyWorkflow('name: Test', 'msg-1'); + + await waitFor(() => { + expect(mockImportWorkflow).toHaveBeenCalled(); + expect(appliedViaStreamingRef.current).toBe(false); + }); + }); }); From 05644c64ebf32f411f007f14ec3b6a93ba0026e9 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 12:11:41 +0100 Subject: [PATCH 18/18] refactor(useAIWorkflowApplications): remove unused handleApplyWorkflowRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ref was assigned but never called — the Retry toast uses saveWorkflowRef and callers receive handleApplyWorkflow directly from the hook return value. --- .../hooks/useAIWorkflowApplications.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts index cb30761198..29b3c5767d 100644 --- a/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts +++ b/assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts @@ -169,12 +169,6 @@ 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); - // Stable ref for save-only retries (importWorkflow already succeeded) const saveWorkflowRef = useRef< ((opts?: { silent?: boolean }) => Promise) | null @@ -319,8 +313,7 @@ export function useAIWorkflowApplications({ ] ); - // Keep refs pointing at the latest callbacks so Retry toast closures never go stale - handleApplyWorkflowRef.current = handleApplyWorkflow; + // Keep ref pointing at the latest callback so Retry toast closures never go stale saveWorkflowRef.current = saveWorkflow; /**