diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts index ec1db9f489..7bdc99537e 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts @@ -83,6 +83,7 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.editor.backAria': 'Back to workflows', 'workflowActivityVNext.editor.canvas': 'Canvas', 'workflowActivityVNext.editor.canvasAria': 'Workflow canvas', + 'workflowActivityVNext.editor.canvasUpdateFailed': "Couldn't update workflow", 'workflowActivityVNext.editor.description': 'Build, test, and refine this workflow.', 'workflowActivityVNext.editor.discardLeave': 'Discard and leave', diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts index 604a533159..8a98d8eea2 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts @@ -87,6 +87,7 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.editor.backAria': '返回工作流列表', 'workflowActivityVNext.editor.canvas': '画布', 'workflowActivityVNext.editor.canvasAria': '工作流画布', + 'workflowActivityVNext.editor.canvasUpdateFailed': '无法更新工作流', 'workflowActivityVNext.editor.description': '构建、测试并完善这个工作流。', 'workflowActivityVNext.editor.discardLeave': '放弃并离开', 'workflowActivityVNext.editor.emptyCanvas': diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface.tsx new file mode 100644 index 0000000000..fbaa8749ff --- /dev/null +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import WorkflowStudioCanvasRegion from './WorkflowStudioCanvasRegion'; +import WorkflowStudioNodeLibrary from './WorkflowStudioNodeLibrary'; + +type WorkflowStudioCanvasRegionProps = React.ComponentProps< + typeof WorkflowStudioCanvasRegion +>; + +type WorkflowStudioEditingCallbacks = Required< + Pick< + WorkflowStudioCanvasRegionProps, + | 'onCanvasSelect' + | 'onConnectNodes' + | 'onDeleteEdges' + | 'onDeleteNodes' + | 'onEdgeSelect' + | 'onNodeLayoutChange' + | 'onNodeSelect' + > +>; + +type WorkflowStudioEditorSurfaceProps = Omit< + WorkflowStudioCanvasRegionProps, + keyof WorkflowStudioEditingCallbacks | 'children' +> & + WorkflowStudioEditingCallbacks & { + readonly children?: React.ReactNode; + readonly nodeLibraryOpen: boolean; + readonly onCloseNodeLibrary: () => void; + readonly onInsertNode: (stepType: string) => void; + }; + +const WorkflowStudioEditorSurface: React.FC< + WorkflowStudioEditorSurfaceProps +> = ({ + children, + nodeLibraryOpen, + onCloseNodeLibrary, + onInsertNode, + ...canvasProps +}) => ( + + + {children} + +); + +export default WorkflowStudioEditorSurface; diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts index b968220054..139bf8631c 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts @@ -30,6 +30,7 @@ import { removeStep, removeStepConnection, type StudioStepInspectorDraft, + suggestBranchLabelForStep, } from '@/shared/studio/document'; import { buildExecutionTrace, @@ -233,7 +234,7 @@ type TeamMemberWorkflowStudioState = { readonly closeNodeLibrary: () => void; readonly closeYamlPanel: () => void; readonly connectNodes: (sourceNodeId: string, targetNodeId: string) => void; - readonly deleteSelectedConnection: () => void; + readonly deleteSelectedConnection: (edgeId?: string) => void; readonly deleteSelectedNode: () => void; readonly dirty: boolean; readonly emptyDescription: string; @@ -3202,10 +3203,18 @@ export function useTeamMemberWorkflowStudio(): TeamMemberWorkflowStudioState { return; } + const sourceStep = editableDocument.steps?.find( + (step) => trimOptional(step.id) === sourceStepId, + ); + const branchLabel = suggestBranchLabelForStep( + trimOptional(sourceStep?.type), + sourceStep?.branches ?? {}, + ); const result = connectStepToTarget( editableDocument, sourceStepId, targetStepId, + branchLabel, ); setEditableDocument(result.document); setSelectedEdgeId(''); @@ -3242,27 +3251,30 @@ export function useTeamMemberWorkflowStudio(): TeamMemberWorkflowStudioState { setSelectedNodeId(result.nodeId); markDraftDirty(); }, [editableDocument, markDraftDirty, selectedNodeId]); - const deleteSelectedConnection = React.useCallback(() => { - if (!editableDocument || !selectedEdgeId) { - return; - } + const deleteSelectedConnection = React.useCallback( + (edgeId: string = selectedEdgeId) => { + if (!editableDocument || !edgeId) { + return; + } - const connection = readConnectionFromGraphEdgeId(selectedEdgeId); - if (!connection) { - return; - } + const connection = readConnectionFromGraphEdgeId(edgeId); + if (!connection) { + return; + } - const result = removeStepConnection( - editableDocument, - connection.sourceStepId, - connection.targetStepId, - connection.branchLabel, - ); - setEditableDocument(result.document); - setSelectedEdgeId(''); - setSelectedNodeId(''); - markDraftDirty(); - }, [editableDocument, markDraftDirty, selectedEdgeId]); + const result = removeStepConnection( + editableDocument, + connection.sourceStepId, + connection.targetStepId, + connection.branchLabel, + ); + setEditableDocument(result.document); + setSelectedEdgeId(''); + setSelectedNodeId(''); + markDraftDirty(); + }, + [editableDocument, markDraftDirty, selectedEdgeId], + ); const updateSelectedStepConfiguration = React.useCallback( (parametersText: string) => { if (!editableDocument || !selectedStepDraft) { diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx index 94f079762e..429fbaf6fd 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx @@ -58,6 +58,7 @@ jest.mock('@/shared/graphs/GraphCanvas', () => ({ edges?: Array<{ id?: string }>; onCanvasSelect?: () => void; onConnectNodes?: (sourceNodeId: string, targetNodeId: string) => void; + onDeleteEdges?: (edgeIds: string[]) => Promise | void; onEdgeSelect?: (edgeId: string) => void; onNodeLayoutChange?: ( nodes: Array<{ id?: string; position?: { x: number; y: number } }>, @@ -91,13 +92,24 @@ jest.mock('@/shared/graphs/GraphCanvas', () => ({ ), props.edges?.map((edge) => React.createElement( - 'button', - { - key: edge.id, - onClick: () => props.onEdgeSelect?.(String(edge.id ?? '')), - type: 'button', - }, - `edge:${edge.id}`, + React.Fragment, + { key: edge.id }, + React.createElement( + 'button', + { + onClick: () => props.onEdgeSelect?.(String(edge.id ?? '')), + type: 'button', + }, + `edge:${edge.id}`, + ), + React.createElement( + 'button', + { + onClick: () => props.onDeleteEdges?.([String(edge.id ?? '')]), + type: 'button', + }, + `delete edge:${edge.id}`, + ), ), ), React.createElement( @@ -3102,7 +3114,94 @@ describe('TeamMemberWorkflowStudioPage', () => { }); }); - it('deletes a selected connection without deleting either node', async () => { + it('connects conditional nodes through the shared branch-aware Studio editor', async () => { + window.history.replaceState( + {}, + '', + '/scopes/scope-1/teams/t-alpha/members/member-alpha/workflow?workflowId=workflow-alpha', + ); + (studioApi.getMember as jest.Mock).mockResolvedValue({ + implementationRef: { + implementationKind: 'workflow', + workflowId: 'workflow-alpha', + }, + summary: { + createdAt: '2026-06-08T00:00:00Z', + description: '', + displayName: 'Workflow Alpha', + implementationKind: 'workflow', + lastBoundRevisionId: null, + lifecycleStage: 'created', + memberId: 'member-alpha', + publishedServiceId: '', + scopeId: 'scope-1', + teamId: 't-alpha', + updatedAt: '2026-06-08T00:00:00Z', + }, + }); + (studioApi.getWorkflow as jest.Mock).mockResolvedValue({ + directoryId: 'scope:scope-1', + directoryLabel: 'scope-1', + draftExists: true, + fileName: 'workflow-alpha.yaml', + filePath: 'scope://scope-1/workflow-alpha.yaml', + findings: [], + layout: null, + name: 'Workflow Alpha', + workflowId: 'workflow-alpha', + yaml: 'name: Workflow Alpha\nsteps: []\n', + document: { + ...mockWorkflowDocument, + steps: [ + { + id: 'condition', + type: 'conditional', + targetRole: '', + parameters: {}, + next: null, + branches: {}, + }, + { + id: 'transform', + type: 'transform', + targetRole: '', + parameters: {}, + next: null, + branches: {}, + }, + ], + }, + updatedAtUtc: '2026-06-08T00:00:00Z', + }); + + renderWithQueryClient(React.createElement(TeamMemberWorkflowStudioPage)); + + await waitFor(() => { + expect(screen.getByTestId('graph-canvas')).toHaveTextContent('nodes:2'); + }); + fireEvent.click( + screen.getByRole('button', { name: 'connect first two nodes' }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(studioApi.serializeYaml).toHaveBeenCalledWith( + expect.objectContaining({ + document: expect.objectContaining({ + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'condition', + branches: { true: 'transform' }, + next: null, + }), + ]), + }), + }), + ); + }); + }); + + it('deletes the connection requested by the canvas without deleting either node', async () => { window.history.replaceState( {}, '', @@ -3171,17 +3270,10 @@ describe('TeamMemberWorkflowStudioPage', () => { expect(screen.getByText('nodes:2')).toBeTruthy(); }); fireEvent.click( - screen.getByRole('button', { name: 'edge:edge:triage:publish:linear' }), + screen.getByRole('button', { + name: 'delete edge:edge:triage:publish:linear', + }), ); - openMoreActionsMenu(); - expect( - screen.getByRole('menuitem', { name: 'Delete selected connection' }), - ).toBeTruthy(); - closeOpenMenu(); - const confirmSpy = jest - .spyOn(window, 'confirm') - .mockImplementation(() => true); - clickMoreAction('Delete selected connection'); expect( screen.queryByRole('button', { name: 'edge:edge:triage:publish:linear', @@ -3192,10 +3284,6 @@ describe('TeamMemberWorkflowStudioPage', () => { expect( screen.queryByRole('button', { name: 'More workflow actions' }), ).toBeNull(); - expect(confirmSpy).toHaveBeenCalledWith( - 'Delete the selected connection? This cannot be undone.', - ); - confirmSpy.mockRestore(); fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx index 39ff4935d6..3daba028a0 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx @@ -1,12 +1,11 @@ import { Alert, Spin } from 'antd'; import React from 'react'; import { t } from '@/shared/i18n/messages'; -import WorkflowStudioCanvas from './components/WorkflowStudioCanvas'; import WorkflowStudioDraftRunPanel from './components/WorkflowStudioDraftRunPanel'; +import WorkflowStudioEditorSurface from './components/WorkflowStudioEditorSurface'; import WorkflowStudioExecutionPanel from './components/WorkflowStudioExecutionPanel'; import WorkflowStudioHeader from './components/WorkflowStudioHeader'; import WorkflowStudioNodeDetailPanel from './components/WorkflowStudioNodeDetailPanel'; -import WorkflowStudioNodeLibrary from './components/WorkflowStudioNodeLibrary'; import WorkflowStudioYamlPanel from './components/WorkflowStudioYamlPanel'; import { useTeamMemberWorkflowStudio } from './hooks/useTeamMemberWorkflowStudio'; @@ -331,16 +330,19 @@ const TeamMemberWorkflowStudioPage: React.FC = () => { ) : ( - { - if (edgeIds.includes(studio.selectedEdgeId)) { - studio.deleteSelectedConnection(); + const [edgeId] = edgeIds; + if (edgeId) { + studio.deleteSelectedConnection(edgeId); } }} onDeleteNodes={(nodeIds) => { @@ -349,17 +351,25 @@ const TeamMemberWorkflowStudioPage: React.FC = () => { } }} onEdgeSelect={studio.selectEdge} + onInsertNode={studio.insertNode} onNodeLayoutChange={studio.moveNodes} onNodeSelect={studio.selectNode} selectedEdgeId={studio.selectedEdgeId} selectedNodeId={studio.selectedNodeId} - /> + > + {studio.draftRunPanelOpen || studio.yamlPanelOpen ? null : ( + + )} + )} - {sidePanelOpen ? (
{ open={studio.yamlPanelOpen} width={sidePanelWidth} /> - {studio.draftRunPanelOpen || studio.yamlPanelOpen ? null : ( - - )} {executionPanelOpen ? (
(null); const runInFlightRef = React.useRef(false); const runGenerationRef = React.useRef(0); @@ -233,6 +239,8 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { setSaveError(''); setStructuralMutationError(''); setFailedNodeType(null); + setCanvasMutationError(''); + setSelectedEdgeId(''); setSelectedNodeId(''); setSelectedStepConfigurationError(''); if (source.data.document) return; @@ -372,6 +380,7 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { setStructuralMutationPending(false); setStructuralMutationError(''); setFailedNodeType(null); + setCanvasMutationError(''); setSaveError(''); setRunInput(''); setRunInputError(''); @@ -381,6 +390,7 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { sseRunIdRef.current = ''; setSseRunId(''); setSelectedNodeId(''); + setSelectedEdgeId(''); setSelectedStepConfigurationError(''); }, [materialization.reset], @@ -504,7 +514,8 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { const current = document ?? (await parseCurrentYaml()); if (!current || generation !== structuralMutationGenerationRef.current) return false; - const explicitDocument = materializeImplicitSequentialTransitions(current); + const explicitDocument = + materializeImplicitSequentialTransitions(current); const selectedStepId = selectedNodeId.startsWith('step:') ? selectedNodeId.slice('step:'.length).trim() : ''; @@ -551,6 +562,130 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { () => buildStudioGraphElements(document, layout), [document, layout], ); + const applyCanvasDocumentMutation = React.useCallback( + async ( + mutate: (current: StudioWorkflowDocument) => { + document: StudioWorkflowDocument; + nodeId: string; + }, + ): Promise => { + if (savingRef.current || structuralMutationPendingRef.current) + return false; + const generation = ++structuralMutationGenerationRef.current; + structuralMutationPendingRef.current = true; + setStructuralMutationPending(true); + setCanvasMutationError(''); + try { + const current = document ?? (await parseCurrentYaml()); + if (!current || generation !== structuralMutationGenerationRef.current) + return false; + const result = mutate(current); + const serialized = await studioApi.serializeYaml({ + document: result.document, + }); + if (generation !== structuralMutationGenerationRef.current) + return false; + setDocument(serialized.document); + setYaml(serialized.yaml); + setFindings(serialized.findings); + setSelectedEdgeId(''); + setSelectedNodeId(result.nodeId); + setSelectedStepConfigurationError(''); + markLocalEdit(); + return true; + } catch (error) { + if (generation === structuralMutationGenerationRef.current) { + setCanvasMutationError(toErrorMessage(error)); + } + return false; + } finally { + if (generation === structuralMutationGenerationRef.current) { + structuralMutationPendingRef.current = false; + setStructuralMutationPending(false); + } + } + }, + [document, markLocalEdit, parseCurrentYaml], + ); + const connectNodes = React.useCallback( + (sourceNodeId: string, targetNodeId: string) => + applyCanvasDocumentMutation((current) => { + const currentGraph = buildStudioGraphElements(current, layout); + const sourceStepId = currentGraph.nodes.find( + (node) => node.id === sourceNodeId, + )?.data.stepId; + const targetStepId = currentGraph.nodes.find( + (node) => node.id === targetNodeId, + )?.data.stepId; + if (!sourceStepId || !targetStepId || sourceStepId === targetStepId) { + return { document: current, nodeId: sourceNodeId }; + } + const sourceStep = current.steps?.find( + (step) => String(step.id ?? '').trim() === sourceStepId, + ); + const branchLabel = suggestBranchLabelForStep( + String(sourceStep?.type ?? '').trim(), + sourceStep?.branches ?? {}, + ); + return connectStepToTarget( + current, + sourceStepId, + targetStepId, + branchLabel, + ); + }), + [applyCanvasDocumentMutation, layout], + ); + const deleteNodes = React.useCallback( + (nodeIds: readonly string[]) => + applyCanvasDocumentMutation((current) => { + const currentGraph = buildStudioGraphElements(current, layout); + const stepIds = nodeIds + .map( + (nodeId) => + currentGraph.nodes.find((node) => node.id === nodeId)?.data + .stepId, + ) + .filter((stepId): stepId is string => Boolean(stepId)); + return removeSteps(current, stepIds); + }), + [applyCanvasDocumentMutation, layout], + ); + const deleteEdges = React.useCallback( + (edgeIds: readonly string[]) => + applyCanvasDocumentMutation((current) => { + const currentGraph = buildStudioGraphElements(current, layout); + let result = { document: current, nodeId: selectedNodeId }; + for (const edgeId of edgeIds) { + const edge = currentGraph.edges.find((entry) => entry.id === edgeId); + const sourceStepId = currentGraph.nodes.find( + (node) => node.id === edge?.source, + )?.data.stepId; + const targetStepId = currentGraph.nodes.find( + (node) => node.id === edge?.target, + )?.data.stepId; + if (!sourceStepId || !targetStepId) continue; + result = removeStepConnection( + result.document, + sourceStepId, + targetStepId, + edge?.data?.branchLabel ?? null, + ); + } + return result; + }), + [applyCanvasDocumentMutation, layout, selectedNodeId], + ); + const moveNodes = React.useCallback( + (nodes: ReturnType['nodes']) => { + if (savingRef.current || structuralMutationPendingRef.current) return; + setLayout((current: unknown) => + buildStudioWorkflowLayout(workflowTitle, nodes, current), + ); + markLocalEdit(); + }, + [markLocalEdit, workflowTitle], + ); const selectedStepDraft = React.useMemo(() => { const selectedStepId = selectedNodeId.startsWith('step:') ? selectedNodeId.slice('step:'.length).trim() @@ -788,9 +923,14 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { document, findings, graph, + canvasMutationError, + connectNodes, + deleteEdges, + deleteNodes, loading: source.isPending, loadError: source.error, materialization, + moveNodes, nodeInsertionError: structuralMutationError, preparePublication, receiptPending, @@ -813,13 +953,21 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { structuralMutationPending, sseRunId, selectedNodeId, + selectedEdgeId, selectedStepConfigurationError, selectedStepDraft, selectCanvas: () => { + setSelectedEdgeId(''); + setSelectedNodeId(''); + setSelectedStepConfigurationError(''); + }, + selectEdge: (edgeId: string) => { + setSelectedEdgeId(edgeId); setSelectedNodeId(''); setSelectedStepConfigurationError(''); }, selectNode: (nodeId: string) => { + setSelectedEdgeId(''); setSelectedNodeId(nodeId); setSelectedStepConfigurationError(''); }, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx index 6fe7027041..689a6f72a2 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx @@ -365,12 +365,33 @@ jest.mock( __esModule: true, default: ({ nodes, + onConnectNodes, + onDeleteEdges, + onDeleteNodes, + onEdgeSelect, + onNodeLayoutChange, onNodeSelect, }: { nodes: readonly { readonly id: string }[]; + onConnectNodes?: (sourceNodeId: string, targetNodeId: string) => void; + onDeleteEdges?: (edgeIds: string[]) => Promise | void; + onDeleteNodes?: (nodeIds: string[]) => Promise | void; + onEdgeSelect?: (edgeId: string) => void; + onNodeLayoutChange?: ( + nodes: readonly { + readonly id: string; + readonly position?: { readonly x: number; readonly y: number }; + }[], + ) => void; onNodeSelect?: (nodeId: string) => void; }) => ( -
+
{nodes.map((node) => (
), }), @@ -2924,6 +2952,61 @@ describe('Workflow Activity vNext editor', () => { ).toBeInTheDocument(); }); + it('reuses the complete Studio canvas editing contract', async () => { + const sourceDocument = { + name: 'committed_source', + roles: [], + steps: [ + { id: 'step-root', type: 'conditional' }, + { id: 'step-next', type: 'transform' }, + ], + }; + mockStudioApi.getWorkflow.mockResolvedValue({ + workflowId: 'wf-committed-source', + name: 'Committed source', + fileName: 'committed-source.yaml', + filePath: '', + directoryId: '', + directoryLabel: '', + yaml: 'name: committed_source\nroles: []\nsteps: []\n', + updatedAtUtc: '2026-08-04T10:00:00Z', + document: sourceDocument, + draftExists: false, + findings: [], + }); + mockStudioApi.serializeYaml.mockImplementation(async ({ document }) => ({ + yaml: 'name: committed_source\nroles: []\nsteps: []\n', + document, + findings: [], + })); + + renderWithQueryClient(); + + const canvas = await screen.findByTestId('workflow-studio-canvas'); + expect(canvas).toHaveAttribute('data-connectable', 'true'); + expect(canvas).toHaveAttribute('data-deletable', 'true'); + expect(canvas).toHaveAttribute('data-edge-selectable', 'true'); + expect(canvas).toHaveAttribute('data-layout-editable', 'true'); + + fireEvent.click( + within(canvas).getByRole('button', { name: 'Connect first two nodes' }), + ); + + await waitFor(() => + expect(mockStudioApi.serializeYaml).toHaveBeenCalledWith({ + document: expect.objectContaining({ + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'step-root', + branches: { true: 'step-next' }, + next: null, + }), + ]), + }), + }), + ); + }); + it('keeps the Canvas/YAML editor view switch discoverable and keyboard operable', async () => { renderWithQueryClient(); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx index 582569f549..bc322879ce 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx @@ -10,8 +10,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { Alert, Button, Input, Modal, Segmented, Space, Tooltip } from 'antd'; import React from 'react'; -import WorkflowStudioCanvasRegion from '@/pages/team-member-workflow-studio/components/WorkflowStudioCanvasRegion'; -import WorkflowStudioNodeLibrary from '@/pages/team-member-workflow-studio/components/WorkflowStudioNodeLibrary'; +import WorkflowStudioEditorSurface from '@/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface'; import { scopesApi } from '@/shared/api/scopesApi'; import { formatUtcDateTime } from '@/shared/datetime/dateTime'; import { t } from '@/shared/i18n/messages'; @@ -438,6 +437,16 @@ const WorkflowEditorPage: React.FC<{ ); }, [editor.nodeInsertionError, editor.retryNodeInsertion, toast]); + React.useEffect(() => { + if (!editor.canvasMutationError) return; + toast.error( + t( + 'workflowActivityVNext.editor.canvasUpdateFailed', + "Couldn't update workflow", + ), + ); + }, [editor.canvasMutationError, toast]); + const retryMaterialization = React.useCallback(async () => { await editor.retryMaterialization(); }, [editor.retryMaterialization]); @@ -1091,7 +1100,7 @@ const WorkflowEditorPage: React.FC<{
) : null} {mode === 'canvas' ? ( - { if (!editorWriteLocked) setNodeLibraryOpen(true); }} onCanvasSelect={requestCanvasSelect} + onConnectNodes={(sourceNodeId, targetNodeId) => { + requestInspectorDiscard(() => { + void editor.connectNodes(sourceNodeId, targetNodeId); + }); + }} + onCloseNodeLibrary={() => setNodeLibraryOpen(false)} + onDeleteEdges={(edgeIds) => { + requestInspectorDiscard(() => { + void editor.deleteEdges(edgeIds); + }); + }} + onDeleteNodes={(nodeIds) => { + requestInspectorDiscard(() => { + void editor.deleteNodes(nodeIds); + }); + }} + onEdgeSelect={(edgeId) => { + requestInspectorDiscard(() => editor.selectEdge(edgeId)); + }} + onInsertNode={(stepType) => { + requestInspectorDiscard(() => { + void editor.addNode(stepType); + setNodeLibraryOpen(false); + }); + }} + onNodeLayoutChange={editor.moveNodes} onNodeSelect={requestNodeSelect} + selectedEdgeId={editor.selectedEdgeId} selectedNodeId={editor.selectedNodeId} style={{ border: '1px solid var(--wa-line)', @@ -1126,16 +1163,6 @@ const WorkflowEditorPage: React.FC<{ > {t('workflowActivityVNext.editor.addNode', 'Add node')} - setNodeLibraryOpen(false)} - onInsertNode={(stepType) => { - requestInspectorDiscard(() => { - void editor.addNode(stepType); - setNodeLibraryOpen(false); - }); - }} - open={nodeLibraryOpen && !editorWriteLocked} - /> - + ) : ( { mockControlsRender(props); return null; }, - Handle: (props: { - className?: string; - position?: string; - type?: string; - }) => + Handle: (props: { className?: string; position?: string; type?: string }) => React.createElement('span', { className: props.className, 'data-position': props.position, @@ -155,6 +151,77 @@ describe('GraphCanvas', () => { expect(onDeleteEdges).toHaveBeenCalledWith(['edge:assert:publish:linear']); }); + it('makes the selected edge visually distinct without changing other edges', () => { + const styledEdges = [ + { + ...edges[0], + markerEnd: { + color: '#2F6FEC', + height: 11, + type: 'arrowclosed', + width: 11, + }, + style: { + opacity: 0.9, + stroke: '#2F6FEC', + strokeWidth: 2.5, + }, + }, + { + ...edges[0], + id: 'edge:publish:archive:linear', + markerEnd: { + color: '#8B5CF6', + height: 11, + type: 'arrowclosed', + width: 11, + }, + source: 'step:publish', + style: { + stroke: '#8B5CF6', + strokeWidth: 2.5, + }, + target: 'step:archive', + }, + ]; + + render( + , + ); + + const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; + const selectedEdge = reactFlowProps.edges[0]; + const unselectedEdge = reactFlowProps.edges[1]; + + expect(selectedEdge.selected).toBe(true); + expect(selectedEdge.style).toEqual( + expect.objectContaining({ + filter: 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))', + opacity: 0.9, + stroke: 'var(--ant-color-primary)', + strokeWidth: 4, + }), + ); + expect(selectedEdge.markerEnd).toEqual({ + color: '#1677ff', + height: 11, + type: 'arrowclosed', + width: 11, + }); + expect(unselectedEdge).toEqual( + expect.objectContaining({ + markerEnd: styledEdges[1].markerEnd, + selected: false, + style: styledEdges[1].style, + }), + ); + }); + it('renders studio nodes with their product label instead of the backend step type id', () => { render(); diff --git a/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx b/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx index c4abe95e95..31af8b456e 100644 --- a/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx +++ b/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx @@ -1,6 +1,6 @@ import { - ApiOutlined, ApartmentOutlined, + ApiOutlined, AppstoreOutlined, CodeOutlined, DatabaseOutlined, @@ -9,31 +9,31 @@ import { UserOutlined, } from '@ant-design/icons'; import { + applyNodeChanges, Background, BackgroundVariant, Controls, + type Edge, + type FitViewOptions, Handle, MiniMap, + type Node, + type NodeChange, + type NodeProps, Position, ReactFlow, - applyNodeChanges, + type ReactFlowInstance, useEdgesState, useNodesState, useStore, - type Edge, - type FitViewOptions, - type Node, - type NodeChange, - type NodeProps, - type ReactFlowInstance, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import React, { useEffect, useLayoutEffect, useMemo } from 'react'; +import { t } from '@/shared/i18n/messages'; import { getStudioGraphCategory, type StudioGraphNodeData, } from '@/shared/studio/graph'; -import { t } from '@/shared/i18n/messages'; type GraphCanvasProps = { autoFitKey?: string; @@ -84,6 +84,9 @@ const STUDIO_FIT_VIEW_ATTEMPT_COUNT = 3; const STUDIO_NODE_WIDTH = 268; const STUDIO_NODE_COMPACT_WIDTH = 244; const STUDIO_NODE_COMPACT_ZOOM = 0.48; +const SELECTED_EDGE_COLOR = '#1677ff'; +const SELECTED_EDGE_FILTER = 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))'; +const SELECTED_EDGE_STROKE_WIDTH = 4; const studioCanvasCss = ` .studio-canvas { background: #f7f9fc; @@ -327,7 +330,8 @@ function StudioWorkflowNode({ }: NodeProps>) { const category = getStudioGraphCategory(data.stepType); const Icon = - STUDIO_NODE_ICON_BY_CATEGORY[category.key] ?? STUDIO_NODE_ICON_BY_CATEGORY.custom; + STUDIO_NODE_ICON_BY_CATEGORY[category.key] ?? + STUDIO_NODE_ICON_BY_CATEGORY.custom; const zoom = useStore((state) => state.transform[2]); const compact = zoom < STUDIO_NODE_COMPACT_ZOOM; const width = compact ? STUDIO_NODE_COMPACT_WIDTH : STUDIO_NODE_WIDTH; @@ -370,10 +374,12 @@ function StudioWorkflowNode({ ] .filter(Boolean) .join(' ')} - style={{ - width, - '--studio-node-accent': category.color, - } as React.CSSProperties} + style={ + { + width, + '--studio-node-accent': category.color, + } as React.CSSProperties + } > = ({ }, [edges, setLocalEdges]); useLayoutEffect(() => { - if (!autoFitKey || !flowInstance || !isStudioVariant || nodes.length === 0) { + if ( + !autoFitKey || + !flowInstance || + !isStudioVariant || + nodes.length === 0 + ) { return; } @@ -525,12 +536,7 @@ const GraphCanvas: React.FC = ({ window.clearTimeout(timeoutId); }); }; - }, [ - autoFitKey, - flowInstance, - isStudioVariant, - nodes.length, - ]); + }, [autoFitKey, flowInstance, isStudioVariant, nodes.length]); const decoratedNodes = useMemo( () => @@ -577,12 +583,22 @@ const GraphCanvas: React.FC = ({ return { ...edge, selected: isSelected, + markerEnd: + isSelected && edge.markerEnd && typeof edge.markerEnd === 'object' + ? { + ...edge.markerEnd, + color: SELECTED_EDGE_COLOR, + } + : edge.markerEnd, style: { ...edge.style, + filter: isSelected ? SELECTED_EDGE_FILTER : edge.style?.filter, stroke: isSelected ? 'var(--ant-color-primary)' : edge.style?.stroke, - strokeWidth: isSelected ? 3 : (edge.style?.strokeWidth ?? 1.5), + strokeWidth: isSelected + ? SELECTED_EDGE_STROKE_WIDTH + : (edge.style?.strokeWidth ?? 1.5), }, labelStyle: { ...edge.labelStyle, @@ -709,7 +725,9 @@ const GraphCanvas: React.FC = ({ > diff --git a/docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md b/docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md new file mode 100644 index 0000000000..c8a4bfb9c5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md @@ -0,0 +1,271 @@ +# Workflow Edge Selection Visibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a selected workflow connection immediately distinguishable at fitted canvas zoom without changing normal edge semantics or workflow behavior. + +**Architecture:** Keep selection decoration in the shared `GraphCanvas` so Team member Workflow Studio and Workflow Activity vNext continue to use one implementation. Decorate only the selected edge by strengthening its path and marker while preserving every unrelated edge property and restoring the original presentation on deselection. + +**Tech Stack:** React 19, TypeScript, `@xyflow/react`, Jest, Testing Library, Biome + +--- + +## File Structure + +- Modify `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx`: add focused coverage for selected and unselected edge presentation. +- Modify `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx`: strengthen the selected path and clone an object marker definition with the selected color. +- Update `docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md`: mark completed steps as implementation proceeds. + +### Task 1: Lock the selected-edge visual contract + +**Files:** +- Test: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx` + +- [ ] **Step 1: Add the failing component test** + +Add this test inside the existing `describe('GraphCanvas', ...)` block: + +```tsx +it('makes the selected edge visually distinct without changing other edges', () => { + const styledEdges = [ + { + ...edges[0], + markerEnd: { + color: '#2F6FEC', + height: 11, + type: 'arrowclosed', + width: 11, + }, + style: { + opacity: 0.9, + stroke: '#2F6FEC', + strokeWidth: 2.5, + }, + }, + { + ...edges[0], + id: 'edge:publish:archive:linear', + markerEnd: { + color: '#8B5CF6', + height: 11, + type: 'arrowclosed', + width: 11, + }, + source: 'step:publish', + style: { + stroke: '#8B5CF6', + strokeWidth: 2.5, + }, + target: 'step:archive', + }, + ]; + + render( + , + ); + + const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; + const selectedEdge = reactFlowProps.edges[0]; + const unselectedEdge = reactFlowProps.edges[1]; + + expect(selectedEdge.selected).toBe(true); + expect(selectedEdge.style).toEqual( + expect.objectContaining({ + filter: 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))', + opacity: 0.9, + stroke: 'var(--ant-color-primary)', + strokeWidth: 4, + }), + ); + expect(selectedEdge.markerEnd).toEqual({ + color: '#1677ff', + height: 11, + type: 'arrowclosed', + width: 11, + }); + expect(unselectedEdge).toEqual( + expect.objectContaining({ + markerEnd: styledEdges[1].markerEnd, + selected: false, + style: styledEdges[1].style, + }), + ); +}); +``` + +- [ ] **Step 2: Run the test and verify the visual contract fails** + +Run: + +```bash +pnpm --dir apps/aevatar-console-web jest --runInBand src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: FAIL because the selected edge still has `strokeWidth: 3`, has no drop-shadow filter, and retains the original marker color. + +### Task 2: Implement the shared selected-edge decoration + +**Files:** +- Modify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx:573` +- Test: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx` + +- [ ] **Step 1: Add selected-edge presentation constants** + +Place these constants beside the other `GraphCanvas` presentation constants: + +```tsx +const SELECTED_EDGE_COLOR = '#1677ff'; +const SELECTED_EDGE_FILTER = + 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))'; +const SELECTED_EDGE_STROKE_WIDTH = 4; +``` + +- [ ] **Step 2: Apply the stronger path and marker presentation** + +Update the decorated edge returned from `localEdges.map(...)` so the selected edge uses the new constants and an object marker definition is cloned rather than mutated: + +```tsx +return { + ...edge, + selected: isSelected, + markerEnd: + isSelected && edge.markerEnd && typeof edge.markerEnd === 'object' + ? { + ...edge.markerEnd, + color: SELECTED_EDGE_COLOR, + } + : edge.markerEnd, + style: { + ...edge.style, + filter: isSelected ? SELECTED_EDGE_FILTER : edge.style?.filter, + stroke: isSelected + ? 'var(--ant-color-primary)' + : edge.style?.stroke, + strokeWidth: isSelected + ? SELECTED_EDGE_STROKE_WIDTH + : (edge.style?.strokeWidth ?? 1.5), + }, + labelStyle: { + ...edge.labelStyle, + fill: isSelected + ? 'var(--ant-color-primary)' + : edge.labelStyle?.fill, + }, +}; +``` + +String marker references remain unchanged because their shared marker definition cannot be safely recolored from `GraphCanvas`. + +- [ ] **Step 3: Run the focused test and verify it passes** + +Run: + +```bash +pnpm --dir apps/aevatar-console-web jest --runInBand src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: PASS for the complete `GraphCanvas.test.tsx` suite, including the new selected-edge contract. + +- [ ] **Step 4: Commit the test-driven implementation** + +```bash +git add apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx \ + apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx +git commit -m "Improve workflow edge selection visibility" +``` + +### Task 3: Run focused frontend validation + +**Files:** +- Verify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx` +- Verify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx` + +- [ ] **Step 1: Analyze the affected frontend scope** + +Run from the repository root: + +```bash +python3 ~/.codex/skills/frontend-incremental-pr/scripts/frontend_change_scope.py \ + --repo . \ + --base origin/feat/2026-08-04_workflow-activity-vnext +``` + +Expected: `aevatar-console-web` is the affected package, the two graph files are listed for static checking, and the analyzer identifies Jest as the relevant runner. + +- [ ] **Step 2: Run every dependency-related test reported by the analyzer** + +Use the analyzer's exact dependency-related Jest paths and explicitly include: + +```bash +pnpm --dir apps/aevatar-console-web jest --runInBand src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: all scoped suites pass. Do not substitute a full frontend test run. + +- [ ] **Step 3: Run changed-file Biome checks** + +```bash +pnpm --dir apps/aevatar-console-web exec biome check \ + src/shared/graphs/GraphCanvas.tsx \ + src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: both files pass. Do not run a local production build or full TypeScript check; GitHub CI owns those checks. + +- [ ] **Step 4: Review the final diff** + +```bash +git diff origin/feat/2026-08-04_workflow-activity-vnext...HEAD -- \ + apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx \ + apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx \ + docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md \ + docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md +git diff --check +``` + +Expected: the diff contains only the agreed edge-selection presentation, its test, and planning documents, with no publish changes or whitespace errors. + +### Task 4: Verify the authenticated editor and update PR #3276 + +**Files:** +- Browser verify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx` +- PR update: `https://github.com/aevatarAI/aevatar/pull/3276` + +- [ ] **Step 1: Reuse the authenticated local editor** + +Open the existing Chrome tab at: + +```text +http://127.0.0.1:5174/scopes/ccb108c4-dcb3-473a-a0f7-e9859bb2f2a0/workflow-activity-vnext/workflows/e4c08548f56b473eb965c94df542463d +``` + +Expected: `weekly_report_five_nodes` renders with five nodes and four edges, without an authentication wall, startup failure, blank page, or initial API error. + +- [ ] **Step 2: Select one edge and inspect the result** + +Click one connection only. Do not invoke Save, Publish, Run, or Delete. + +Expected: the selected path has a computed 4 px stroke, a visible blue drop shadow, a synchronized selected arrow marker, and is immediately distinguishable from adjacent unselected edges at the fitted zoom. + +- [ ] **Step 3: Push the implementation commit** + +```bash +git push origin HEAD:fix/2026-08-06_one-click-workflow-publish +``` + +Expected: PR #3276 updates without force-pushing. + +- [ ] **Step 4: Update the PR verification evidence** + +Record the exact focused Jest and Biome commands and results in PR #3276. State explicitly: + +```markdown +- Full frontend suite/build: deferred to GitHub CI by personal local workflow policy +``` + +Do not wait for CI after the PR update unless the user requests CI monitoring. diff --git a/docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md b/docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md new file mode 100644 index 0000000000..e7d6c5d244 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md @@ -0,0 +1,45 @@ +# Workflow Edge Selection Visibility + +## Context + +Workflow Studio and Workflow Activity vNext both render editable workflow connections through the shared `GraphCanvas`. A normal linear connection is already rendered as a 2.5 px blue stroke. Selecting it currently changes the stroke to a similar blue and increases its width to only 3 px. At the fitted zoom used for workflows with several nodes, that 0.5 px difference is difficult to perceive. The arrow marker also keeps its normal appearance, so it does not reinforce the selection state. + +The edge is selected correctly in application state and React Flow. This change therefore addresses only the shared visual presentation. It does not alter workflow editing, deletion, saving, publishing, or backend behavior. + +## Design + +Selected edges will retain the existing Ant Design primary color while receiving three coordinated cues: + +- Increase the selected path stroke width to 4 px. +- Add a restrained blue drop shadow around the selected path so the state remains visible after canvas zooming. +- Update the arrow marker color to the same selected primary color. + +Normal linear and branch edges will retain their existing semantic colors and widths. Selection will remain static rather than animated to avoid unnecessary motion and visual noise in an operational editor. + +The styling will remain in the shared `GraphCanvas` edge decoration path so Team member Workflow Studio and Workflow Activity vNext use exactly the same behavior. No page-specific override or second edge component will be introduced. + +## State Flow + +The owning editor continues to provide `selectedEdgeId`. `GraphCanvas` compares that identifier with each rendered edge and decorates only the matching edge. Deselecting the edge restores the original edge style and marker configuration without mutating the source graph data. + +## Error Handling + +This is a deterministic presentation change and introduces no new asynchronous work or failure state. Existing editor error handling and toast behavior remain unchanged. + +## Verification + +Focused component coverage will verify that: + +- the selected edge receives the stronger stroke and drop shadow; +- the selected arrow marker uses the selected color; +- unselected edges preserve their original style and marker color; +- selecting an edge still preserves its other edge configuration. + +The existing authenticated local Workflow Activity vNext editor will then be used for a browser smoke check at its current fitted zoom. The selected connection must be immediately distinguishable from adjacent unselected connections without invoking Save, Publish, Run, or Delete. + +## Scope Boundaries + +- No publish code or publication contract changes. +- No workflow identity, routing, API, or backend changes. +- No animation or custom edge renderer. +- No changes to the meaning of linear and branch edge colors.