diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json
index 26bcf67b72c..78ad6a4ef36 100644
--- a/Localize/lang/strings.json
+++ b/Localize/lang/strings.json
@@ -143,11 +143,13 @@
"1D047X": "Required. The value to convert to XML.",
"1Fn5n+": "Required. The URI encoded string.",
"1GWzEL": "No results found for the specified filters",
+ "1Gsrs4": "You can ask questions or describe changes you want to make to your workflow.",
"1HhCtq": "Headers",
"1KFpTX": "(UTC+03:00) Minsk",
"1KMc+6": "The integer should be between [{min}, {max}]",
"1LSKq8": "Basics",
"1NBvKu": "Convert the parameter argument to a floating-point number",
+ "1NVeRR": "This assistant can help you learn about your workflows, answer questions about Azure Logic Apps, and make changes to your workflow.",
"1Orv4i": "Details",
"1REu5/": "See less",
"1Xke9D": "open functions drawer",
@@ -331,6 +333,7 @@
"5L2vIX": "Subscription",
"5LV34t": "The ''{invalidProperties}'' properties are invalid for the ''{authType}'' authentication type.",
"5OvGgn": "Body",
+ "5R1r3q": "Create a new connection",
"5SAQOb": "Authority",
"5Tqzsm": "Categorization",
"5U6Dee": "Action Result",
@@ -897,6 +900,7 @@
"I2Ztna": "Loop automatically added when connecting a repeating source element. No function required.",
"I3mifR": "Is skipped",
"I41vZ/": "(UTC-11:00) Coordinated Universal Time-11",
+ "I9lfbc": "✅ Workflow updated successfully.",
"IA+Ogm": "22",
"IAmvpa": "(UTC-08:00) Coordinated Universal Time-08",
"IBFBR2": "Remove loop",
@@ -1173,6 +1177,7 @@
"OdNhwc": "Ungroup",
"OeSQhS": "Create a new Azure Storage Account",
"Oep6va": "Submit",
+ "OevhEs": "↩️ Workflow change has been undone.",
"OgJ9eG": "(UTC+08:00) Taipei",
"OhbvXz": "(UTC+11:00) Norfolk Island",
"Oib1mL": "{hours}h {minutes}m",
@@ -1321,6 +1326,7 @@
"RvpHdu": "(UTC+11:00) Solomon Is., New Caledonia",
"RxGxr+": "Line number",
"RxbkcI": "Unsupported token type: {controls}",
+ "S+9l11": "Ask a question or describe a workflow change...",
"S0N/tx": "Resubmit a workflow run from this action",
"S138/4": "Format text as bold. Shortcut: ⌘B",
"S2KtbJ": "Select date and time",
@@ -1506,6 +1512,7 @@
"Vq9q5J": "Built-in",
"Vqs8hE": "Actions",
"VtRDnx": "Set the valid duration for this API key. Securely save the key after generation. This key appears only once and isn't stored in Azure, so you can't view the key later. Lost API keys require regenerating new ones.",
+ "VuvZff": "Add a response action that returns a 200 status code.",
"Vx6fwP": "Added this action",
"VysSj3": "View code",
"W+mUyI": "Next",
@@ -1637,7 +1644,6 @@
"YTJ78g": "Learn how to assign it",
"YUbSFS": "Yes/No",
"YV6qd0": "Agent activity",
- "YW1rx0": "Create a new connection",
"YWD/RY": "condition, collapse",
"YWws/r": "Output names should not be empty.",
"YX0jQs": "All",
@@ -1850,11 +1856,13 @@
"_1D047X.comment": "Required string parameter to be converted using xml function",
"_1Fn5n+.comment": "Required URI encoded string parameter to be converted using uriComponentToBinary function",
"_1GWzEL.comment": "Text displayed when no results are found in the browse grid",
+ "_1Gsrs4.comment": "Chatbot outro message when workflow editing is enabled",
"_1HhCtq.comment": "headers",
"_1KFpTX.comment": "Time zone value ",
"_1KMc+6.comment": "Error validation message for integers being out of range",
"_1LSKq8.comment": "Accessibility label for the basics section",
"_1NBvKu.comment": "Label for description of custom float Function",
+ "_1NVeRR.comment": "Chatbot introduction message when workflow editing is enabled",
"_1Orv4i.comment": "Title for the details section",
"_1REu5/.comment": "Select to view fewer token options.",
"_1Xke9D.comment": "aria label to open functions drawer",
@@ -2038,6 +2046,7 @@
"_5L2vIX.comment": "Label for subscription id field",
"_5LV34t.comment": "Error message when having multiple invalid authentication properties",
"_5OvGgn.comment": "The title of the body field in the static result query action",
+ "_5R1r3q.comment": "Button text to create a new connection",
"_5SAQOb.comment": "Authority Label Display Name",
"_5Tqzsm.comment": "Categorization section title",
"_5U6Dee.comment": "The label for the action result dropdown in the unit test panel.",
@@ -2604,6 +2613,7 @@
"_I2Ztna.comment": "Message explaining user does not need to add a loop function",
"_I3mifR.comment": "Skipped run",
"_I41vZ/.comment": "Time zone value ",
+ "_I9lfbc.comment": "Chatbot message confirming a workflow modification was applied",
"_IA+Ogm.comment": "Hour of the day",
"_IAmvpa.comment": "Time zone value ",
"_IBFBR2.comment": "Remove loop for the connection",
@@ -2880,6 +2890,7 @@
"_OdNhwc.comment": "Ungroup button",
"_OeSQhS.comment": "Description for the Azure Storage Account create popup",
"_Oep6va.comment": "Submit button",
+ "_OevhEs.comment": "Chatbot message confirming a workflow modification was undone",
"_OgJ9eG.comment": "Time zone value ",
"_OhbvXz.comment": "Time zone value ",
"_Oib1mL.comment": "This is a time duration in abbreviated format",
@@ -3028,6 +3039,7 @@
"_RvpHdu.comment": "Time zone value ",
"_RxGxr+.comment": "The title of the line number field in the static result parseJson action",
"_RxbkcI.comment": "Exception for unsupported token types",
+ "_S+9l11.comment": "Chatbot input placeholder when workflow editing is enabled",
"_S0N/tx.comment": "accessibility text for the resubmit button",
"_S138/4.comment": "label to make bold text for Mac users",
"_S2KtbJ.comment": "Label for custom date time picker",
@@ -3213,6 +3225,7 @@
"_Vq9q5J.comment": "Filter by In App category of connectors",
"_Vqs8hE.comment": "Actions button",
"_VtRDnx.comment": "Description for the MCP generate keys section",
+ "_VuvZff.comment": "Chatbot suggestion message to add a response action to the workflow",
"_Vx6fwP.comment": "Chatbot added operation sentence format",
"_VysSj3.comment": "Button for View Code",
"_W+mUyI.comment": "Placeholder text for the Next button in the suggested workflow description",
@@ -3344,7 +3357,6 @@
"_YTJ78g.comment": "Link text to learn how to assign the required role for the session pool in Azure Container Apps",
"_YUbSFS.comment": "Placeholder title for a newly inserted Boolean parameter",
"_YV6qd0.comment": "Chat view tab title",
- "_YW1rx0.comment": "Button text to create a new connection",
"_YWD/RY.comment": "condition",
"_YWws/r.comment": "Invalid output names",
"_YX0jQs.comment": "All templates tab",
@@ -3562,7 +3574,6 @@
"_dDYCuU.comment": "Link text to open URL",
"_dEe6Ob.comment": "Error validation message",
"_dIYzFU.comment": "Tooltip text for the \"...\" menu that you select to show more items",
- "_dK7mXq.comment": "Info message shown on parameters step when the connection is locked",
"_dKCp2j.comment": "Chatbot query start of sentence for asking for more explaination on an item that the user can should complete.",
"_dKW11v.comment": "Info message during workflow selection",
"_dL9V5t.comment": "Text to show label for managed identity selector",
@@ -3738,6 +3749,7 @@
"_h8JTcA.comment": "Shows how many tools are selected",
"_hA5Aif.comment": "The tab label for the publish tab on the configure template wizard",
"_hCrg+6.comment": "Cannot delete the last run after edge",
+ "_hFjNA8.comment": "Chatbot introduction message to suggest what it can help with",
"_hGbRBS.comment": "All tab label",
"_hHDJhD.comment": "Placeholder title for a newly inserted File parameter",
"_hHNj31.comment": "Cancel button text",
@@ -3878,7 +3890,6 @@
"_k8fofe.comment": "Error message shown when app creation fails",
"_kBSLfu.comment": "Duplicate property name error message",
"_kEI2xx.comment": "The title of the message field in the static result parseJson action",
- "_kEjmTx.comment": "Chatbot introduction message to suggest what it can help with",
"_kH7x1w.comment": "Label for the App Service plan field",
"_kHcCxH.comment": "This is a time duration in abbreviated format",
"_kHs5R4.comment": "Chatbot flow preview message reminding user to check workflow actions",
@@ -3933,6 +3944,7 @@
"_lL5oRE.comment": "Microsoft tab label",
"_lLhS3T.comment": "Button text for creating new workflows",
"_lM9qrG.comment": "Time zone value ",
+ "_lO+med.comment": "Chatbot message when a workflow modification is proposed for review",
"_lPTdSf.comment": "Button text for run trigger",
"_lQNKUB.comment": "Describes connection being added",
"_lR7V87.comment": "Section header for the functions section",
@@ -3952,6 +3964,7 @@
"_ljAOR6.comment": "Label for description of custom body Function",
"_lk/Qic.comment": "Connector text",
"_lkgjxD.comment": "Required string parameter for end time",
+ "_lmqT4k.comment": "Info message shown on parameters step when the connection is locked",
"_lo77/t.comment": "Summary label",
"_lqF8H5.comment": "An accessible label for collapse toggle icon",
"_lsH37F.comment": "tool tip explaining what schema validation setting does",
@@ -4120,6 +4133,7 @@
"_p0BE2D.comment": "Button text to trigger clone in the create workflow panel",
"_p1IEXb.comment": "Label for button to open dynamic content token picker",
"_p2eSD1.comment": "Button text for opening panel for editing workflows",
+ "_p4+r7z.comment": "Chatbot suggestion message to add error handling to the workflow",
"_p5ZID0.comment": "Time zone value ",
"_p8AKOz.comment": "Label for the description textfield",
"_pC2nr2.comment": "Placeholder text for Key",
@@ -4615,7 +4629,6 @@
"_zb3lE6.comment": "Chatbot message telling user to set up action in order to save the workflow",
"_zcZpHT.comment": "Label for description of custom parseDateTime Function",
"_zeVnUJ.comment": "Required parameter for value in encodeXmlValue function",
- "_zec5Ay.comment": "Chatbot stop generating flow button alt text",
"_zhMe58.comment": "Label for clear search button",
"_ziYCiA.comment": "Header text for summary",
"_zj7R+4.comment": "Button text for creating the logic app",
@@ -4779,7 +4792,6 @@
"dDYCuU": "Learn more",
"dEe6Ob": "Enter a valid JSON.",
"dIYzFU": "More…",
- "dK7mXq": "The connection for this MCP server is locked and cannot be changed.",
"dKCp2j": "Tell me more about",
"dKW11v": "Currently, templates only support workflows from the same Logic App instance.",
"dL9V5t": "Managed identity",
@@ -4955,6 +4967,7 @@
"h8JTcA": "{count} {count, plural, one {tool} other {tools}} selected",
"hA5Aif": "Publish",
"hCrg+6": "Actions must have one or more run after configurations",
+ "hFjNA8": "Some things you can try:",
"hGbRBS": "All",
"hHDJhD": "File content",
"hHNj31": "Cancel",
@@ -5095,7 +5108,6 @@
"k8fofe": "An error occurred while creating the app. Unknown error.",
"kBSLfu": "Duplicate property name",
"kEI2xx": "Message",
- "kEjmTx": "Some things you can ask:",
"kH7x1w": "App Service plan",
"kHcCxH": "{minutes}m {seconds}s",
"kHs5R4": "Check these actions to see if any parameters need to be set.",
@@ -5150,6 +5162,7 @@
"lL5oRE": "Microsoft",
"lLhS3T": "Create new workflows",
"lM9qrG": "(UTC+13:00) Nuku'alofa",
+ "lO+med": "I have a workflow change ready. Review the proposal below.",
"lPTdSf": "Run trigger",
"lQNKUB": "A line for the parent element is added automatically.",
"lR7V87": "Functions",
@@ -5169,6 +5182,7 @@
"ljAOR6": "Shorthand for actions('actionName').outputs.body",
"lk/Qic": "connector",
"lkgjxD": "Required. A string that contains the end time.",
+ "lmqT4k": "The connection for this MCP server is locked and cannot be changed.",
"lo77/t": "Summary",
"lqF8H5": "Collapse static result",
"lsH37F": "Validate request body against the schema provided. In case there is a mismatch, HTTP 400 will be returned",
@@ -5337,6 +5351,7 @@
"p0BE2D": "Clone",
"p1IEXb": "Enter the data from previous step. You can also add data by typing the '/' character.",
"p2eSD1": "Edit",
+ "p4+r7z": "Add error handling to this workflow.",
"p5ZID0": "(UTC+03:00) Kuwait, Riyadh",
"p8AKOz": "Description",
"pC2nr2": "Enter key",
@@ -5832,7 +5847,6 @@
"zb3lE6": "To save this workflow, finish setting up this action:",
"zcZpHT": "Converts a string, with optionally a locale and a format to a date",
"zeVnUJ": "Required. The string to be encoded as a valid XML element value.",
- "zec5Ay": "Stop",
"zhMe58": "Clear search",
"ziYCiA": "Summary",
"zj7R+4": "Create",
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeView.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeView.tsx
index 2f02d50dcc5..1d24cb8d086 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeView.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeView.tsx
@@ -57,21 +57,26 @@ const CodeViewEditor = forwardRef(({ workflowKind, isConsumption }: CodeViewProp
hasChanges: () => changesMade,
}));
+ if (isNullOrUndefined(code)) {
+ return null;
+ }
+
return (
-
- {isNullOrUndefined(code) ? null : (
-
- )}
+
+
);
});
+CodeViewEditor.displayName = 'CodeViewEditor';
+
export default CodeViewEditor;
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeViewV2.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeViewV2.tsx
index cbd2da80201..5ac00cb5ee8 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeViewV2.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/CodeViewV2.tsx
@@ -58,20 +58,19 @@ const CodeViewEditor = forwardRef(({ workflowKind, isConsumption }: CodeViewProp
hasChanges: () => changesMade,
}));
- return (
-
- {isNullOrUndefined(code) ? null : (
-
- )}
+ return isNullOrUndefined(code) ? null : (
+
+
);
});
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBar.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBar.tsx
index 2c97e0ba0f7..b4967c63b6c 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBar.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBar.tsx
@@ -279,6 +279,20 @@ export const DesignerCommandBar = ({
const editorItems: ICommandBarItemProps[] = useMemo(
() => [
...baseStartItems,
+ {
+ key: 'copilot',
+ text: 'Assistant',
+ iconProps: { iconName: 'Chat' },
+ disabled: !isCopilotReady,
+ onClick: () => {
+ enableCopilot?.();
+ LoggerService().log({
+ level: LogEntryLevel.Warning,
+ area: 'chatbot',
+ message: 'workflow assistant opened',
+ });
+ },
+ },
{
key: 'run',
text: 'Run',
@@ -407,20 +421,6 @@ export const DesignerCommandBar = ({
},
onClick: () => !!dispatch(openPanel({ panelMode: 'Error' })),
},
- {
- key: 'copilot',
- text: 'Assistant',
- iconProps: { iconName: 'Chat' },
- disabled: !isCopilotReady,
- onClick: () => {
- enableCopilot?.();
- LoggerService().log({
- level: LogEntryLevel.Warning,
- area: 'chatbot',
- message: 'workflow assistant opened',
- });
- },
- },
{
key: 'document',
text: 'Document',
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBarV2.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBarV2.tsx
index 638836048e8..0499be37aaf 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBarV2.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBarV2.tsx
@@ -75,6 +75,8 @@ import {
DocumentOnePageColumnsRegular,
ArrowSyncFilled,
CheckmarkCircleRegular,
+ ChatSparkleFilled,
+ ChatSparkleRegular,
} from '@fluentui/react-icons';
const UndoIcon = bundleIcon(ArrowUndoFilled, ArrowUndoRegular);
@@ -85,6 +87,7 @@ const ConnectionsIcon = bundleIcon(LinkFilled, LinkRegular);
const ErrorsIcon = bundleIcon(ErrorCircleFilled, ErrorCircleRegular);
const DocumentOnePageAddIcon = bundleIcon(DocumentOnePageAddFilled, DocumentOnePageAddRegular);
const DocumentOnePageColumnsIcon = bundleIcon(DocumentOnePageColumnsFilled, DocumentOnePageColumnsRegular);
+const ChatIcon = bundleIcon(ChatSparkleFilled, ChatSparkleRegular);
const useStyles = makeStyles({
viewModeContainer: {
@@ -202,7 +205,10 @@ export const DesignerCommandBar = ({
queryClient.invalidateQueries({ queryKey: ['foundryAgentVersions'] });
}).catch(console.error);
await saveWorkflow(serializedWorkflow, customCodeFilesWithData, () => dispatch(resetDesignerDirtyState(undefined)), autoSave);
- if (Object.keys(serializedWorkflow?.definition?.triggers ?? {}).length > 0) {
+ // Only refresh callback URL on explicit saves, not auto-saves.
+ // The callback URL doesn't change on draft saves, so calling it on every
+ // auto-save cycle just generates unnecessary network requests.
+ if (!autoSave && Object.keys(serializedWorkflow?.definition?.triggers ?? {}).length > 0) {
updateCallbackUrl(designerState, dispatch);
}
if (autoSave) {
@@ -470,9 +476,6 @@ export const DesignerCommandBar = ({
Assertions
-
enableCopilot?.()}>
- Assistant
-
downloadDocument()}
@@ -503,6 +506,9 @@ export const DesignerCommandBar = ({
}}
>
+ } disabled={!isCopilotReady} onClick={() => enableCopilot?.()}>
+ Assistant
+
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts
index 470d0b70e0e..32eb7d3d717 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts
@@ -16,13 +16,16 @@ export const Artifact = {
ParametersFile: 'parameters.json',
WorkflowFile: 'workflow.json',
HostFile: 'host.json',
+ NotesFile: 'notes.json',
DraftFile: 'workflow-draft.json',
DraftConnectionsFile: 'connections-draft.json',
DraftParametersFile: 'parameters-draft.json',
+ DraftNotesFile: 'notes-draft.json',
} as const;
export const VfsArtifact = {
NotesFile: 'notes.json',
+ DraftNotesFile: 'notes-draft.json',
} as const;
export interface ArtifactProperties {
@@ -30,6 +33,8 @@ export interface ArtifactProperties {
[Artifact.WorkflowFile]: WorkflowJson;
[Artifact.ParametersFile]: ParametersData;
[Artifact.ConnectionsFile]: ConnectionsData;
+ [Artifact.DraftConnectionsFile]?: ConnectionsData;
+ [Artifact.DraftParametersFile]?: ParametersData;
};
health: {
state: string;
@@ -128,7 +133,6 @@ export interface ConnectionsData {
agentMcpConnections?: Record;
}
-// eslint-disable-next-line @typescript-eslint/no-empty-interface
export type ParametersData = Record;
export interface Parameter {
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx
index 93b60466266..07362850ac5 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx
@@ -981,7 +981,16 @@ export const saveWorkflowStandard = async (
if (isDraftSave) {
if (workflows.length > 0) {
- return deployArtifacts(siteResourceId, workflows[0].name, workflows[0].workflow, connectionsData, parametersData, settings, true);
+ return deployArtifacts(
+ siteResourceId,
+ workflows[0].name,
+ workflows[0].workflow,
+ connectionsData,
+ parametersData,
+ settings,
+ true,
+ notesData
+ );
}
return;
}
@@ -1074,11 +1083,6 @@ export const saveWorkflowConsumption = async (
},
isDraftSave?: boolean
): Promise => {
- // Implement draft save logic for consumption if needed
- if (isDraftSave) {
- return;
- }
-
const shouldConvertToConsumption = options?.shouldConvertToConsumption ?? true;
const workflowToSave = shouldConvertToConsumption ? await convertDesignerWorkflowToConsumptionWorkflow(workflow) : workflow;
@@ -1091,6 +1095,10 @@ export const saveWorkflowConsumption = async (
},
};
+ if (isDraftSave) {
+ return putConsumptionDraftWorkflow(outdatedWorkflow.id, outputWorkflow, { throwError: options?.throwError });
+ }
+
try {
await axios.put(`${baseUrl}${validateResourceId(outdatedWorkflow.id)}?api-version=2016-10-01`, JSON.stringify(outputWorkflow), {
headers: {
@@ -1108,6 +1116,28 @@ export const saveWorkflowConsumption = async (
}
};
+const putConsumptionDraftWorkflow = async (workflowId: string, workflow: any, options?: { throwError?: boolean }): Promise => {
+ try {
+ const response = await axios.put(
+ `${baseUrl}${validateResourceId(workflowId)}/drafts/default?api-version=${consumptionApiVersion}`,
+ JSON.stringify(workflow),
+ {
+ headers: {
+ 'If-Match': '*',
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${environment.armToken}`,
+ },
+ }
+ );
+ return response;
+ } catch (error) {
+ console.log(error);
+ if (options?.throwError) {
+ throw error;
+ }
+ }
+};
+
export const validateWorkflowStandard = async (
siteResourceId: string,
workflowName: string,
@@ -1279,7 +1309,8 @@ export const deployArtifacts = async (
connectionsData?: ConnectionsData,
parametersData?: ParametersData,
settings?: Record,
- isDraft?: boolean
+ isDraft?: boolean,
+ notesData?: Record
) => {
const data: any = {
files: {},
@@ -1295,6 +1326,14 @@ export const deployArtifacts = async (
data.files[isDraft ? Artifact.DraftParametersFile : Artifact.ParametersFile] = parametersData;
}
+ if (notesData) {
+ // Always write to draft notes file; additionally write to prod notes file on publish
+ data.files[`${workflowName}/${Artifact.DraftNotesFile}`] = notesData;
+ if (!isDraft) {
+ data.files[`${workflowName}/${Artifact.NotesFile}`] = notesData;
+ }
+ }
+
if (settings) {
data.appSettings = settings;
}
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts
index 3aae8cb3af3..8d8a97e2403 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts
@@ -110,7 +110,6 @@ export function addOrUpdateAppSettings(settings: Record, origina
// TODO: To show this in a notification of info bar on the blade that key will be overriden.
}
- // eslint-disable-next-line no-param-reassign
originalSettings[settingKey] = settings[settingKey];
}
@@ -118,6 +117,15 @@ export function addOrUpdateAppSettings(settings: Record, origina
}
export const getDataForConsumption = (data: any) => {
+ if (!data) {
+ return {
+ workflow: undefined,
+ connectionReferences: undefined,
+ parameters: undefined,
+ notes: undefined,
+ };
+ }
+
const properties = data?.properties as any;
const definition = removeProperties(properties?.definition, ['parameters']);
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx
index 322b8e233a0..618a1e5b787 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx
@@ -32,6 +32,8 @@ import {
BaseApiManagementService,
BaseAppServiceService,
BaseChatbotService,
+ BaseCopilotWorkflowEditorService,
+ InitCopilotWorkflowEditorService,
BaseExperimentationService,
BaseUserPreferenceService,
BaseFunctionService,
@@ -70,6 +72,9 @@ import {
getMissingRoleDefinitions,
roleQueryKeys,
isAgentWorkflow,
+ setIsWorkflowDirty,
+ setFocusNode,
+ changePanelNode,
} from '@microsoft/logic-apps-designer';
import axios from 'axios';
import isEqual from 'lodash.isequal';
@@ -127,12 +132,17 @@ const DesignerEditor = () => {
const originalConnectionsData = useMemo(() => data?.properties.files[Artifact.ConnectionsFile] ?? {}, [data?.properties.files]);
const originalCustomCodeData = useMemo(() => Object.keys(customCodeData ?? {}), [customCodeData]);
const parameters = useMemo(() => data?.properties.files[Artifact.ParametersFile] ?? {}, [data?.properties.files]);
+ const [currentParameters, setCurrentParameters] = useState(parameters);
const queryClient = getReactQueryClient();
- const displayCopilotChatbot = showChatBot && designerView;
+
+ useEffect(() => {
+ setCurrentParameters(parameters);
+ }, [parameters]);
const connectionsData = useMemo(
- () => resolveConnectionsReferences(JSON.stringify(clone(originalConnectionsData ?? {})), parameters, settingsData?.properties ?? {}),
- [originalConnectionsData, parameters, settingsData?.properties]
+ () =>
+ resolveConnectionsReferences(JSON.stringify(clone(originalConnectionsData ?? {})), currentParameters, settingsData?.properties ?? {}),
+ [originalConnectionsData, currentParameters, settingsData?.properties]
);
const addConnectionDataInternal = async (connectionAndSetting: ConnectionAndAppSetting): Promise => {
@@ -481,7 +491,7 @@ const DesignerEditor = () => {
workflow={{
definition: workflow?.definition,
connectionReferences,
- parameters,
+ parameters: currentParameters,
kind: workflow?.kind,
}}
workflowId={workflow?.id}
@@ -502,22 +512,44 @@ const DesignerEditor = () => {
onClose={() => dispatch(setRunHistoryEnabled(false))}
onRunSelected={onRunSelected}
/>
- {displayCopilotChatbot ? (
+ {designerView ? (
openPanel('Azure Copilot Panel has been opened')}
getAuthToken={getAuthToken}
getUpdatedWorkflow={getUpdatedWorkflow}
openFeedbackPanel={() => openPanel('Azure Feedback Panel has been opened')}
closeChatBot={() => dispatch(setIsChatBotEnabled(false))}
+ enableWorkflowEditing={true}
+ autoApply={true}
+ onWorkflowProposed={(newWorkflow) => {
+ if (newWorkflow.parameters) {
+ setCurrentParameters(newWorkflow.parameters as unknown as ParametersData);
+ }
+ setWorkflow({
+ ...newWorkflow,
+ id: guid(),
+ });
+ DesignerStore.dispatch(setIsWorkflowDirty(true));
+ }}
+ getNodeVisuals={(nodeId) => {
+ const meta = DesignerStore.getState().operations.operationMetadata[nodeId];
+ return meta ? { iconUri: meta.iconUri, brandColor: meta.brandColor } : undefined;
+ }}
+ onNodeClick={(nodeId) => {
+ DesignerStore.dispatch(setFocusNode(nodeId));
+ DesignerStore.dispatch(changePanelNode(nodeId));
+ }}
/>
) : null}
{
switchViews={handleSwitchView}
saveWorkflowFromCode={saveWorkflowFromCode}
/>
- {designerView ? : }
+
+ {designerView ? : }
+
@@ -908,6 +942,20 @@ const getDesignerServices = (
location,
});
+ // Initialize CopilotWorkflowEditorService if API key is configured
+ const copilotEditorApiKey = import.meta.env.VITE_COPILOT_EDITOR_API_KEY;
+ const copilotEditorEndpoint = import.meta.env.VITE_COPILOT_EDITOR_ENDPOINT;
+ if (copilotEditorApiKey && copilotEditorEndpoint) {
+ const copilotEditorService = new BaseCopilotWorkflowEditorService({
+ endpoint: copilotEditorEndpoint,
+ apiKey: copilotEditorApiKey,
+ model: import.meta.env.VITE_COPILOT_EDITOR_MODEL || undefined,
+ deploymentName: import.meta.env.VITE_COPILOT_EDITOR_DEPLOYMENT || undefined,
+ apiVersion: import.meta.env.VITE_COPILOT_EDITOR_API_VERSION || undefined,
+ });
+ InitCopilotWorkflowEditorService(copilotEditorService);
+ }
+
const customCodeService = new StandardCustomCodeService({
apiVersion: '2018-11-01',
baseUrl: armUrl,
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerConsumption.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerConsumption.tsx
index c318a5fbdbf..111fe8944f2 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerConsumption.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerConsumption.tsx
@@ -63,7 +63,6 @@ const httpClient = new HttpClient();
const DesignerEditorConsumption = () => {
const dispatch = useDispatch();
const { id: workflowId } = useSelector((state: RootState) => ({
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: state.workflowLoader.resourcePath!,
}));
@@ -329,17 +328,25 @@ const DesignerEditorConsumption = () => {
onClose={() => dispatch(setRunHistoryEnabled(false))}
onRunSelected={onRunSelected}
/>
- {showChatBot ? (
- {
- dispatch(setIsChatBotEnabled(false));
- }}
- getAuthToken={getAuthToken}
- />
- ) : null}
-
+
{
+ dispatch(setIsChatBotEnabled(false));
+ }}
+ getAuthToken={getAuthToken}
+ />
+
{
const dispatch = useDispatch();
const { id: workflowId } = useSelector((state: RootState) => ({
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: state.workflowLoader.resourcePath!,
}));
@@ -398,7 +403,7 @@ const DesignerEditorConsumption = () => {
return;
}
- if (isDraftMode && draftWorkflow) {
+ if (isDraftMode && draftWorkflow?.definition) {
setConnectionReferences(draftConnectionReferences);
setParameters(draftParameters);
setNotes(draftNotes);
@@ -469,17 +474,37 @@ const DesignerEditorConsumption = () => {
isMultiVariableEnabled={hostOptions.enableMultiVariable}
>
- {showChatBot ? (
-
{
- dispatch(setIsChatBotEnabled(false));
- }}
- getAuthToken={getAuthToken}
- />
- ) : null}
-
+
{
+ dispatch(setIsChatBotEnabled(false));
+ }}
+ getAuthToken={getAuthToken}
+ enableWorkflowEditing={true}
+ autoApply={true}
+ onWorkflowProposed={(newWorkflow) => {
+ setNotes(newWorkflow.notes ?? {});
+ if (newWorkflow.parameters) {
+ setParameters(newWorkflow.parameters);
+ }
+ setWorkflow({
+ ...newWorkflow,
+ id: guid(),
+ });
+ DesignerStore.dispatch(setIsWorkflowDirty(true));
+ }}
+ getNodeVisuals={(nodeId) => {
+ const meta = DesignerStore.getState().operations.operationMetadata[nodeId];
+ return meta ? { iconUri: meta.iconUri, brandColor: meta.brandColor } : undefined;
+ }}
+ onNodeClick={(nodeId) => {
+ DesignerStore.dispatch(setFocusNode(nodeId));
+ DesignerStore.dispatch(changePanelNode(nodeId));
+ }}
+ />
+
{
{isCodeView ? (
) : (
-
+ <>
{
isConsumption={true}
workflowReadOnly={derivedIsReadOnly}
/>
-
+ >
)}
@@ -754,6 +779,21 @@ const getDesignerServices = (
location: 'westcentralus',
});
+ // Initialize CopilotWorkflowEditorService if API key is configured
+ const copilotEditorApiKey = import.meta.env.VITE_COPILOT_EDITOR_API_KEY;
+ const copilotEditorEndpoint = import.meta.env.VITE_COPILOT_EDITOR_ENDPOINT;
+ if (copilotEditorApiKey && copilotEditorEndpoint) {
+ const copilotEditorService = new BaseCopilotWorkflowEditorService({
+ endpoint: copilotEditorEndpoint,
+ apiKey: copilotEditorApiKey,
+ model: import.meta.env.VITE_COPILOT_EDITOR_MODEL || undefined,
+ deploymentName: import.meta.env.VITE_COPILOT_EDITOR_DEPLOYMENT || undefined,
+ apiVersion: import.meta.env.VITE_COPILOT_EDITOR_API_VERSION || undefined,
+ systemPrompt: CONSUMPTION_SYSTEM_PROMPT,
+ });
+ InitCopilotWorkflowEditorService(copilotEditorService);
+ }
+
// This isn't correct but without it I was getting errors
// It's fine just to unblock standalone consumption
const customCodeService = new StandardCustomCodeService({
diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx
index c4b2cc5a137..ee72ceebe32 100644
--- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx
+++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx
@@ -32,6 +32,8 @@ import {
BaseApiManagementService,
BaseAppServiceService,
BaseChatbotService,
+ BaseCopilotWorkflowEditorService,
+ InitCopilotWorkflowEditorService,
BaseExperimentationService,
BaseUserPreferenceService,
BaseFunctionService,
@@ -70,6 +72,9 @@ import {
roleQueryKeys,
isAgentWorkflow,
useRun,
+ setIsWorkflowDirty,
+ setFocusNode,
+ changePanelNode,
} from '@microsoft/logic-apps-designer-v2';
import axios from 'axios';
import isEqual from 'lodash.isequal';
@@ -128,20 +133,53 @@ const DesignerEditor = () => {
const [isDraftMode, setIsDraftMode] = useState(true);
const codeEditorRef = useRef<{ getValue: () => string | undefined; hasChanges: () => boolean }>(null);
- const originalConnectionsData = useMemo(
+ const prodConnectionsData = useMemo(
() => artifactData?.properties.files[Artifact.ConnectionsFile] ?? {},
[artifactData?.properties.files]
);
+ const draftConnectionsData = useMemo(
+ () => artifactData?.properties.files[Artifact.DraftConnectionsFile],
+ [artifactData?.properties.files]
+ );
+ const originalConnectionsData = useMemo(() => {
+ if (isDraftMode && draftConnectionsData && Object.keys(draftConnectionsData).length > 0) {
+ return draftConnectionsData;
+ }
+ return prodConnectionsData;
+ }, [isDraftMode, draftConnectionsData, prodConnectionsData]);
const originalCustomCodeData = useMemo(() => Object.keys(customCodeData ?? {}), [customCodeData]);
const prodWorkflow = useMemo(() => artifactData?.properties.files[Artifact.WorkflowFile], [artifactData?.properties.files]);
const draftWorkflow = useMemo(() => customCodeData?.[Artifact.DraftFile], [customCodeData]);
- const parameters = useMemo(() => artifactData?.properties.files[Artifact.ParametersFile] ?? {}, [artifactData?.properties.files]);
- const notes = useMemo(() => customCodeData?.[VfsArtifact.NotesFile] ?? {}, [customCodeData]);
+ const prodParameters = useMemo(() => artifactData?.properties.files[Artifact.ParametersFile] ?? {}, [artifactData?.properties.files]);
+ const draftParameters = useMemo(() => artifactData?.properties.files[Artifact.DraftParametersFile], [artifactData?.properties.files]);
+ const parameters = useMemo(() => {
+ if (isDraftMode && draftParameters && Object.keys(draftParameters).length > 0) {
+ return draftParameters;
+ }
+ return prodParameters;
+ }, [isDraftMode, draftParameters, prodParameters]);
+ const prodNotes = useMemo(() => customCodeData?.[VfsArtifact.NotesFile] ?? {}, [customCodeData]);
+ const draftNotes = useMemo(() => customCodeData?.[VfsArtifact.DraftNotesFile], [customCodeData]);
+ const initialNotes = useMemo(() => {
+ if (isDraftMode && draftNotes && Object.keys(draftNotes).length > 0) {
+ return draftNotes;
+ }
+ return prodNotes;
+ }, [isDraftMode, draftNotes, prodNotes]);
+ const [currentNotes, setCurrentNotes] = useState(initialNotes);
+ const [currentParameters, setCurrentParameters] = useState(parameters ?? {});
+
+ useEffect(() => {
+ setCurrentNotes(initialNotes);
+ }, [initialNotes]);
+ useEffect(() => {
+ setCurrentParameters(parameters ?? {});
+ }, [parameters]);
const queryClient = getReactQueryClient();
- const displayCopilotChatbot = showChatBot && isDesignerView;
const connectionsData = useMemo(
- () => resolveConnectionsReferences(JSON.stringify(clone(originalConnectionsData ?? {})), parameters, settingsData?.properties ?? {}),
- [originalConnectionsData, parameters, settingsData?.properties]
+ () =>
+ resolveConnectionsReferences(JSON.stringify(clone(originalConnectionsData ?? {})), currentParameters, settingsData?.properties ?? {}),
+ [originalConnectionsData, currentParameters, settingsData?.properties]
);
const connectionReferences = WorkflowUtility.convertConnectionsDataToReferences(connectionsData);
const { data: runInstanceData } = useRun(runId);
@@ -300,8 +338,8 @@ const DesignerEditor = () => {
return { ...(settingsData?.properties ?? {}) };
}, [settingsData?.properties]);
- const originalParametersData: ParametersData = clone(parameters ?? {});
- const originalNotesData: NotesData = clone(notes ?? {});
+ const originalParametersData: ParametersData = clone(currentParameters ?? {});
+ const originalNotesData: NotesData = clone(currentNotes ?? {});
const saveWorkflowFromDesigner = useCallback(
async (
@@ -318,6 +356,7 @@ const DesignerEditor = () => {
};
delete workflowToSave.id;
+ delete workflowToSave.notes;
const newManagedApiConnections = {
...(connectionsData?.managedApiConnections ?? {}),
@@ -428,6 +467,10 @@ const DesignerEditor = () => {
isDraftSave
);
+ // Invalidate cached workflow artifacts so the next load fetches fresh data
+ // (including any new connection references added during this session)
+ getReactQueryClient().invalidateQueries(['workflowArtifactsStandard', workflowId]);
+
return workflowToSave;
},
[
@@ -441,6 +484,7 @@ const DesignerEditor = () => {
settingsData?.properties,
siteResourceId,
workflow,
+ workflowId,
workflowName,
]
);
@@ -620,8 +664,8 @@ const DesignerEditor = () => {
workflow={{
definition: workflow?.definition,
connectionReferences,
- parameters,
- notes,
+ parameters: currentParameters,
+ notes: currentNotes,
kind: workflow?.kind,
}}
workflowId={workflow?.id}
@@ -630,69 +674,85 @@ const DesignerEditor = () => {
appSettings={settingsData?.properties}
isMultiVariableEnabled={hostOptions.enableMultiVariable && !isMonitoringView}
>
+ dispatch(setIsChatBotEnabled(!showChatBot))}
+ saveWorkflowFromCode={saveWorkflowFromCode}
+ showMonitoringView={showMonitoringView}
+ showDesignerView={showDesignerView}
+ showCodeView={showCodeView}
+ switchWorkflowMode={switchWorkflowMode}
+ isDraftMode={isDraftMode}
+ prodWorkflow={artifactData?.properties.files[Artifact.WorkflowFile]}
+ />
- {displayCopilotChatbot ? (
+
openPanel('Azure Copilot Panel has been opened')}
getAuthToken={getAuthToken}
getUpdatedWorkflow={getUpdatedWorkflow}
openFeedbackPanel={() => openPanel('Azure Feedback Panel has been opened')}
closeChatBot={() => dispatch(setIsChatBotEnabled(false))}
+ enableWorkflowEditing={true}
+ autoApply={true}
+ onWorkflowProposed={(newWorkflow) => {
+ setCurrentNotes(newWorkflow.notes ?? {});
+ if (newWorkflow.parameters) {
+ setCurrentParameters(newWorkflow.parameters as unknown as ParametersData);
+ }
+ setWorkflow({
+ ...newWorkflow,
+ id: guid(),
+ });
+ DesignerStore.dispatch(setIsWorkflowDirty(true));
+ }}
+ getNodeVisuals={(nodeId) => {
+ const meta = DesignerStore.getState().operations.operationMetadata[nodeId];
+ return meta ? { iconUri: meta.iconUri, brandColor: meta.brandColor } : undefined;
+ }}
+ onNodeClick={(nodeId) => {
+ DesignerStore.dispatch(setFocusNode(nodeId));
+ DesignerStore.dispatch(changePanelNode(nodeId));
+ }}
/>
- ) : null}
-
-
dispatch(setIsChatBotEnabled(!showChatBot))}
- saveWorkflowFromCode={saveWorkflowFromCode}
- showMonitoringView={showMonitoringView}
- showDesignerView={showDesignerView}
- showCodeView={showCodeView}
- switchWorkflowMode={switchWorkflowMode}
- isDraftMode={isDraftMode}
- prodWorkflow={artifactData?.properties.files[Artifact.WorkflowFile]}
- />
- {!isCodeView && (
-
-
-
-
- )}
- {isCodeView && }
-
-
+ {!isCodeView && (
+
+
+
+
+ )}
+ {isCodeView && }
+
+
) : null}
@@ -1059,6 +1119,20 @@ const getDesignerServices = (
location,
});
+ // Initialize CopilotWorkflowEditorService if API key is configured
+ const copilotEditorApiKey = import.meta.env.VITE_COPILOT_EDITOR_API_KEY;
+ const copilotEditorEndpoint = import.meta.env.VITE_COPILOT_EDITOR_ENDPOINT;
+ if (copilotEditorApiKey && copilotEditorEndpoint) {
+ const copilotEditorService = new BaseCopilotWorkflowEditorService({
+ endpoint: copilotEditorEndpoint,
+ apiKey: copilotEditorApiKey,
+ model: import.meta.env.VITE_COPILOT_EDITOR_MODEL || undefined,
+ deploymentName: import.meta.env.VITE_COPILOT_EDITOR_DEPLOYMENT || undefined,
+ apiVersion: import.meta.env.VITE_COPILOT_EDITOR_API_VERSION || undefined,
+ });
+ InitCopilotWorkflowEditorService(copilotEditorService);
+ }
+
const customCodeService = new StandardCustomCodeService({
apiVersion: '2018-11-01',
baseUrl: armUrl,
diff --git a/apps/vs-code-react/src/app/designer/CodeViewEditor/index.tsx b/apps/vs-code-react/src/app/designer/CodeViewEditor/index.tsx
index a63ea504b20..eb010fe5249 100644
--- a/apps/vs-code-react/src/app/designer/CodeViewEditor/index.tsx
+++ b/apps/vs-code-react/src/app/designer/CodeViewEditor/index.tsx
@@ -62,19 +62,18 @@ const CodeViewEditor = forwardRef(({ workflowKind, workflowFile }: CodeViewProps
hasChanges: () => changesMade,
}));
- return (
-
- {isNullOrUndefined(code) ? null : (
-
- )}
+ return isNullOrUndefined(code) ? null : (
+
+
);
});
diff --git a/libs/chatbot/src/lib/ui/ChatbotUi.tsx b/libs/chatbot/src/lib/ui/ChatbotUi.tsx
index 419157bc00b..3ae27930385 100644
--- a/libs/chatbot/src/lib/ui/ChatbotUi.tsx
+++ b/libs/chatbot/src/lib/ui/ChatbotUi.tsx
@@ -1,7 +1,8 @@
-import { type ITextField, Panel, PanelType, useTheme } from '@fluentui/react';
-import { mergeClasses } from '@fluentui/react-components';
+import { Button, InlineDrawer, DrawerBody, DrawerHeader, DrawerHeaderTitle } from '@fluentui/react-components';
+import { DismissRegular, ShieldCheckmarkRegular } from '@fluentui/react-icons';
import {
ChatInput,
+ type ChatInputHandle,
ChatSuggestion,
ChatSuggestionGroup,
type ConversationItem,
@@ -9,10 +10,9 @@ import {
PanelLocation,
ProgressCardWithStopButton,
} from '@microsoft/designer-ui';
-import { useEffect, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
-import { useChatbotStyles, useChatbotDarkStyles } from './styles';
-import { ShieldCheckmarkRegular } from '@fluentui/react-icons';
+import { useChatbotStyles } from './styles';
export const defaultChatbotPanelWidth = '360px';
@@ -21,16 +21,12 @@ interface ChatbotUIProps {
width?: string;
location?: PanelLocation;
isOpen?: boolean;
- hasCloseButton?: boolean;
- isBlocking?: boolean;
onDismiss?: () => void;
header?: React.ReactNode;
};
inputBox: {
disabled?: boolean;
- value?: string;
placeholder?: string;
- onChange?: (value: string) => void;
onSubmit: (value: string) => void;
readOnly?: boolean;
};
@@ -67,35 +63,21 @@ const QUERY_MAX_LENGTH = 2000;
export const ChatbotUI = (props: ChatbotUIProps) => {
const {
body: { messages, focus, answerGenerationInProgress, setFocus, focusMessageId, clearFocusMessageId },
- inputBox: { disabled, placeholder, value = '', onChange, onSubmit, readOnly },
+ inputBox: { disabled, placeholder, onSubmit, readOnly },
data: { isSaving, canSave, canTest, test, save, abort } = {},
string: { test: testString, save: saveString, submit: submitString, progressState, progressStop, progressSave, protectedMessage },
} = props;
const intl = useIntl();
- const { isInverted } = useTheme();
- const textInputRef = useRef
(null);
+ const textInputRef = useRef(null);
+ const [inputValue, setInputValue] = useState('');
+
+ const handleSubmit = useCallback(() => {
+ onSubmit(inputValue);
+ setInputValue('');
+ }, [onSubmit, inputValue]);
// Styles
const styles = useChatbotStyles();
- const darkStyles = useChatbotDarkStyles();
-
- const inputIconButtonStyles = useMemo(
- () => ({
- enabled: {
- root: {
- backgroundColor: 'transparent',
- color: isInverted ? 'rgb(200, 200, 200)' : 'rgb(51, 51, 51)',
- },
- },
- disabled: {
- root: {
- backgroundColor: 'transparent',
- color: isInverted ? 'rgb(79, 79, 79)' : 'rgb(200, 200, 200)',
- },
- },
- }),
- [isInverted]
- );
useEffect(() => {
if (focus) {
@@ -141,16 +123,15 @@ export const ChatbotUI = (props: ChatbotUIProps) => {
}, [intl]);
const inputDisabled = !!(answerGenerationInProgress || disabled);
- const trimmedLength = value.trim().length;
+ const trimmedLength = inputValue.trim().length;
const submitDisabled = answerGenerationInProgress || trimmedLength < QUERY_MIN_LENGTH;
const resolvedPlaceholder = placeholder ?? intlText.inputPlaceHolder;
const showFooter = protectedMessage || canSave || canTest || !readOnly;
return (
-
- {props?.panel?.header}
-
+
+
{answerGenerationInProgress && (
)}
@@ -174,22 +155,18 @@ export const ChatbotUI = (props: ChatbotUIProps) => {
) : null}
{readOnly ? null : (
onChange?.(newValue ?? '')}
+ onQueryChange={(_ev, newValue) => setInputValue(newValue ?? '')}
placeholder={resolvedPlaceholder}
- query={value}
+ query={inputValue}
showCharCount
submitButtonProps={{
title: submitString ?? intlText.submitButton,
disabled: submitDisabled,
- iconProps: {
- iconName: 'Send',
- styles: submitDisabled ? inputIconButtonStyles.disabled : inputIconButtonStyles.enabled,
- },
- onClick: () => onSubmit(value),
+ onClick: handleSubmit,
}}
/>
)}
@@ -201,20 +178,28 @@ export const ChatbotUI = (props: ChatbotUIProps) => {
export const AssistantChat = (props: ChatbotUIProps) => {
const {
- panel: { width = defaultChatbotPanelWidth, location = PanelLocation.Left, isOpen, hasCloseButton = false, isBlocking, onDismiss },
+ panel: { width = defaultChatbotPanelWidth, location = PanelLocation.Left, isOpen, onDismiss, header },
} = props;
+ const intl = useIntl();
+ const closeButtonLabel = intl.formatMessage({
+ defaultMessage: 'Close',
+ id: 'ZihyUf',
+ description: 'Label for the close button in the chatbot header',
+ });
+
return (
-
-
-
+
+
+ } onClick={onDismiss} />}
+ >
+ {header}
+
+
+
+
+
+
);
};
diff --git a/libs/chatbot/src/lib/ui/CopilotChatbot.tsx b/libs/chatbot/src/lib/ui/CopilotChatbot.tsx
index 9e6f28905da..920b722f774 100644
--- a/libs/chatbot/src/lib/ui/CopilotChatbot.tsx
+++ b/libs/chatbot/src/lib/ui/CopilotChatbot.tsx
@@ -3,37 +3,66 @@ import type { RequestData } from '../common/models/Query';
import { isSuccessResponse } from '../core/util';
import { CopilotPanelHeader } from './panelheader';
import { getId } from '@fluentui/react';
-import { LogEntryLevel, LoggerService, ChatbotService, guid, type Workflow } from '@microsoft/logic-apps-shared';
+import {
+ LogEntryLevel,
+ LoggerService,
+ ChatbotService,
+ CopilotWorkflowEditorService,
+ isCopilotWorkflowEditorServiceInitialized,
+ guid,
+ fallbackConnectorIconUrl,
+ type Workflow,
+ type WorkflowChange,
+ WorkflowChangeType,
+ WorkflowChangeTargetType,
+} from '@microsoft/logic-apps-shared';
import type { ConversationItem, ChatEntryReaction, AdditionalParametersItem } from '@microsoft/designer-ui';
-import { PanelLocation, ConversationItemType, FlowOrigin } from '@microsoft/designer-ui';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { PanelLocation, ConversationItemType, FlowOrigin, UndoStatus } from '@microsoft/designer-ui';
+import { useCallback, useMemo, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { AssistantChat, defaultChatbotPanelWidth } from './ChatbotUi';
-interface CoPilotChatbotProps {
+export interface CoPilotChatbotProps {
panelLocation?: PanelLocation;
+ /** Controls whether the chatbot drawer is open. The component stays mounted to preserve conversation history. */
+ isOpen?: boolean;
getAuthToken: () => Promise;
getUpdatedWorkflow: () => Promise;
openFeedbackPanel: () => void; // callback when feedback panel is opened
openAzureCopilotPanel?: (prompt?: string) => void; // callback to open Azure Copilot Panel
closeChatBot?: () => void; // callback when chatbot is closed
chatbotWidth?: string;
+ /** When true and CopilotWorkflowEditorService is initialized, workflow edit requests go through the editor service */
+ enableWorkflowEditing?: boolean;
+ /** Called when a workflow modification is proposed/approved. The host is responsible for applying the update. */
+ onWorkflowProposed?: (workflow: Workflow) => void;
+ /** When true, workflow proposals are applied immediately without showing a proposal card. Defaults to true. */
+ autoApply?: boolean;
+ /** Optional callback to resolve a node's icon and brand color by node ID */
+ getNodeVisuals?: (nodeId: string) => { iconUri: string; brandColor: string } | undefined;
+ /** Optional callback when a node ID in the change list is clicked — used to focus/open the node in the editor */
+ onNodeClick?: (nodeId: string) => void;
}
export const CoPilotChatbot = ({
panelLocation = PanelLocation.Left,
+ isOpen = true,
getAuthToken,
getUpdatedWorkflow,
openFeedbackPanel,
openAzureCopilotPanel,
closeChatBot,
chatbotWidth = defaultChatbotPanelWidth,
+ enableWorkflowEditing = false,
+ onWorkflowProposed,
+ autoApply = true,
+ getNodeVisuals,
+ onNodeClick,
}: CoPilotChatbotProps) => {
const chatSessionId = useRef(guid());
const intl = useIntl();
const chatbotService = ChatbotService();
- const [inputQuery, setInputQuery] = useState('');
- const [collapsed, setCollapsed] = useState(false);
+ const workflowEditorEnabled = enableWorkflowEditing && isCopilotWorkflowEditorServiceInitialized();
const [answerGeneration, stopAnswerGeneration] = useState(true);
const [canSaveCurrentFlow, saveCurrentFlow] = useState(false);
const [canTestCurrentFlow, testCurrentFlow] = useState(false);
@@ -46,19 +75,28 @@ export const CoPilotChatbot = ({
id: getId(),
date: new Date(),
reaction: undefined,
+ workflowEditingEnabled: workflowEditorEnabled,
},
]);
const [controller, setController] = useState(new AbortController());
const signal = controller.signal;
const [selectedOperation] = useState('');
+ // Track the last proposed workflow for undo support
+ const pendingProposalRef = useRef<{ workflow: Workflow; previousWorkflow: Workflow; conversationItemId: string } | null>(null);
const intlText = useMemo(() => {
return {
- chatInputPlaceholder: intl.formatMessage({
- defaultMessage: 'Ask a question about this workflow or about Azure Logic Apps as a whole ...',
- id: 'kXn5e0',
- description: 'Chabot input placeholder text',
- }),
+ chatInputPlaceholder: workflowEditorEnabled
+ ? intl.formatMessage({
+ defaultMessage: 'Ask a question or describe a workflow change...',
+ id: 'S+9l11',
+ description: 'Chatbot input placeholder when workflow editing is enabled',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Ask a question about this workflow or about Azure Logic Apps as a whole ...',
+ id: 'kXn5e0',
+ description: 'Chabot input placeholder text',
+ }),
protectedMessage: intl.formatMessage({
defaultMessage: 'Your personal and company data are protected in this chat',
id: 'Yrw/Qt',
@@ -165,8 +203,23 @@ export const CoPilotChatbot = ({
id: 'JKZpcd',
description: 'Chatbot card telling user that the AI response is being canceled',
}),
+ workflowAppliedText: intl.formatMessage({
+ defaultMessage: '✅ Workflow updated successfully.',
+ id: 'I9lfbc',
+ description: 'Chatbot message confirming a workflow modification was applied',
+ }),
+ workflowProposedText: intl.formatMessage({
+ defaultMessage: 'I have a workflow change ready. Review the proposal below.',
+ id: 'lO+med',
+ description: 'Chatbot message when a workflow modification is proposed for review',
+ }),
+ workflowUndoneText: intl.formatMessage({
+ defaultMessage: '↩️ Workflow change has been undone.',
+ id: 'OevhEs',
+ description: 'Chatbot message confirming a workflow modification was undone',
+ }),
};
- }, [intl, selectedOperation]);
+ }, [intl, selectedOperation, workflowEditorEnabled]);
const logFeedbackVote = useCallback((reaction: ChatEntryReaction, isRemovedVote?: boolean) => {
if (isRemovedVote) {
@@ -184,6 +237,332 @@ export const CoPilotChatbot = ({
}
}, []);
+ const enrichChangesWithVisuals = useCallback(
+ (
+ changes: WorkflowChange[] | undefined,
+ currentWorkflow: Workflow,
+ preApplySnapshot?: Map
+ ): WorkflowChange[] | undefined => {
+ if (!changes) {
+ return changes;
+ }
+ const connectionIconUrl = fallbackConnectorIconUrl();
+ const connectionRefs = currentWorkflow.connectionReferences ?? {};
+
+ // Build a normalized key → original key map for case/space-insensitive lookups
+ const normalizeKey = (key: string) => key.toLowerCase().replace(/[\s_]/g, '');
+ const snapshotByNormalized = new Map();
+ if (preApplySnapshot) {
+ for (const [key, value] of preApplySnapshot) {
+ snapshotByNormalized.set(normalizeKey(key), value);
+ }
+ }
+
+ return changes.map((change) => {
+ // Skip if already enriched
+ if (change.iconUri) {
+ return change;
+ }
+
+ // Non-action target types (notes, connections, parameters) don't need connector icon enrichment
+ if (change.targetType && change.targetType !== WorkflowChangeTargetType.Action) {
+ return change;
+ }
+
+ const primaryNodeId = change.nodeIds[0];
+ if (!primaryNodeId) {
+ return { ...change, iconUri: connectionIconUrl, brandColor: '#4E4F4F' };
+ }
+
+ // Check if this is a connection reference change
+ if (primaryNodeId in connectionRefs) {
+ return { ...change, iconUri: connectionIconUrl, brandColor: '#4E4F4F' };
+ }
+
+ // Try the live store first (works for modified nodes, and added nodes after re-render)
+ if (getNodeVisuals) {
+ const visuals = getNodeVisuals(primaryNodeId);
+ if (visuals) {
+ return { ...change, iconUri: visuals.iconUri, brandColor: visuals.brandColor };
+ }
+ }
+
+ // Fall back to pre-apply snapshot (works for deleted nodes)
+ // Try exact match first, then normalized match for LLM name mismatches
+ if (preApplySnapshot) {
+ const snapshotVisuals = preApplySnapshot.get(primaryNodeId);
+ if (snapshotVisuals) {
+ return { ...change, iconUri: snapshotVisuals.iconUri, brandColor: snapshotVisuals.brandColor };
+ }
+ }
+ const normalizedVisuals = snapshotByNormalized.get(normalizeKey(primaryNodeId));
+ if (normalizedVisuals) {
+ return { ...change, iconUri: normalizedVisuals.iconUri, brandColor: normalizedVisuals.brandColor };
+ }
+
+ // Ultimate fallback — use a generic connector icon so there's always something visible
+ return { ...change, iconUri: connectionIconUrl, brandColor: '#4E4F4F' };
+ });
+ },
+ [getNodeVisuals]
+ );
+
+ /**
+ * Schedules a delayed re-enrichment attempt for conversation items whose changes
+ * are still missing icon data (e.g. newly added nodes that the designer hasn't
+ * processed yet).
+ */
+ const scheduleIconEnrichment = useCallback(
+ (
+ responseId: string,
+ rawChanges: WorkflowChange[] | undefined,
+ currentWorkflow: Workflow,
+ preApplySnapshot?: Map,
+ retriesLeft = 5
+ ) => {
+ if (!rawChanges || !getNodeVisuals) {
+ return;
+ }
+ const timer = setTimeout(() => {
+ setConversation((current) =>
+ current.map((item) => {
+ if (item.id !== responseId || item.type !== ConversationItemType.ReplyWithFlow) {
+ return item;
+ }
+ const enriched = enrichChangesWithVisuals(rawChanges, currentWorkflow, preApplySnapshot);
+ const fallbackIcon = fallbackConnectorIconUrl();
+ const stillUsingFallback =
+ enriched?.some((c) => c.changeType === WorkflowChangeType.Added && c.iconUri === fallbackIcon) ?? false;
+ if (stillUsingFallback && retriesLeft > 1) {
+ scheduleIconEnrichment(responseId, rawChanges, currentWorkflow, preApplySnapshot, retriesLeft - 1);
+ }
+ return { ...item, changes: enriched };
+ })
+ );
+ }, 1000);
+ return timer;
+ },
+ [getNodeVisuals, enrichChangesWithVisuals]
+ );
+
+ const handleWorkflowEditResponse = useCallback(
+ async (query: string, requestPayload: RequestData) => {
+ const editorService = CopilotWorkflowEditorService();
+ const currentWorkflow = await getUpdatedWorkflow();
+ const response = await editorService.getWorkflowEdit(query, currentWorkflow, signal);
+
+ if (response.type === 'workflow' && response.workflow) {
+ const proposedWorkflow = response.workflow;
+ const responseId = guid();
+
+ if (autoApply) {
+ // Capture visual metadata for every referenced node BEFORE applying
+ // (deleted nodes will lose their metadata once the designer processes the new workflow)
+ const preApplySnapshot = new Map();
+ if (getNodeVisuals && response.changes) {
+ for (const change of response.changes) {
+ for (const nodeId of change.nodeIds) {
+ const visuals = getNodeVisuals(nodeId);
+ if (visuals) {
+ preApplySnapshot.set(nodeId, visuals);
+ }
+ }
+ }
+ }
+
+ // Auto-apply: immediately call onWorkflowProposed and show confirmation
+ pendingProposalRef.current = {
+ workflow: proposedWorkflow,
+ previousWorkflow: currentWorkflow,
+ conversationItemId: responseId,
+ };
+ onWorkflowProposed?.(proposedWorkflow);
+
+ // Enrich changes with node icons — modified/deleted nodes resolve from
+ // the pre-apply snapshot; for added nodes, schedule a delayed retry
+ const enrichedChanges = enrichChangesWithVisuals(response.changes, currentWorkflow, preApplySnapshot);
+ const fallbackIcon = fallbackConnectorIconUrl();
+ const hasAddedWithFallback =
+ enrichedChanges?.some((c) => c.changeType === WorkflowChangeType.Added && c.iconUri === fallbackIcon) ?? false;
+
+ setConversation((current) => [
+ {
+ type: ConversationItemType.ReplyWithFlow,
+ id: responseId,
+ date: new Date(),
+ text: response.text || intlText.workflowAppliedText,
+ reaction: undefined,
+ undoStatus: UndoStatus.UndoAvailable,
+ correlationId: chatSessionId.current,
+ changes: enrichedChanges,
+ onNodeClick,
+ __rawRequest: requestPayload,
+ __rawResponse: response,
+ openFeedback: openFeedbackPanel,
+ logFeedbackVote,
+ onClick: () => {
+ // Undo handler: revert to previous workflow
+ const proposal = pendingProposalRef.current;
+ if (proposal && proposal.conversationItemId === responseId) {
+ onWorkflowProposed?.(proposal.previousWorkflow);
+ pendingProposalRef.current = null;
+ setConversation((current) =>
+ current.map((item) =>
+ item.id === responseId
+ ? {
+ ...item,
+ undoStatus: UndoStatus.Undone,
+ text: intlText.workflowUndoneText,
+ }
+ : item
+ )
+ );
+ }
+ },
+ },
+ ...current,
+ ]);
+
+ // Re-enrich icons for newly added nodes once designer processes the workflow
+ if (hasAddedWithFallback) {
+ scheduleIconEnrichment(responseId, response.changes, currentWorkflow, preApplySnapshot);
+ }
+ } else {
+ // Proposal mode: show the proposal for user approval
+ pendingProposalRef.current = {
+ workflow: proposedWorkflow,
+ previousWorkflow: currentWorkflow,
+ conversationItemId: responseId,
+ };
+
+ const proposalEnrichedChanges = enrichChangesWithVisuals(response.changes, currentWorkflow);
+
+ setConversation((current) => [
+ {
+ type: ConversationItemType.ReplyWithFlow,
+ id: responseId,
+ date: new Date(),
+ text: response.text || intlText.workflowProposedText,
+ reaction: undefined,
+ undoStatus: UndoStatus.Unavailable,
+ correlationId: chatSessionId.current,
+ changes: proposalEnrichedChanges,
+ onNodeClick,
+ __rawRequest: requestPayload,
+ __rawResponse: response,
+ openFeedback: openFeedbackPanel,
+ logFeedbackVote,
+ onClick: () => {
+ // Approve handler: apply the proposed workflow
+ const proposal = pendingProposalRef.current;
+ if (proposal && proposal.conversationItemId === responseId) {
+ onWorkflowProposed?.(proposal.workflow);
+ setConversation((current) =>
+ current.map((item) =>
+ item.id === responseId
+ ? {
+ ...item,
+ undoStatus: UndoStatus.UndoAvailable,
+ onClick: () => {
+ // After approval, clicking again will undo
+ if (proposal) {
+ onWorkflowProposed?.(proposal.previousWorkflow);
+ pendingProposalRef.current = null;
+ setConversation((c) =>
+ c.map((i) =>
+ i.id === responseId ? { ...i, undoStatus: UndoStatus.Undone, text: intlText.workflowUndoneText } : i
+ )
+ );
+ }
+ },
+ }
+ : item
+ )
+ );
+ }
+ },
+ },
+ ...current,
+ ]);
+
+ // Re-enrich icons for any changes missing visuals
+ const proposalFallbackIcon = fallbackConnectorIconUrl();
+ if (proposalEnrichedChanges?.some((c) => c.changeType === WorkflowChangeType.Added && c.iconUri === proposalFallbackIcon)) {
+ scheduleIconEnrichment(responseId, response.changes, currentWorkflow);
+ }
+ }
+ } else {
+ // Text-only response (question answer, etc.)
+ setConversation((current) => [
+ {
+ type: ConversationItemType.Reply,
+ id: guid(),
+ date: new Date(),
+ text: response.text,
+ isMarkdownText: true,
+ correlationId: chatSessionId.current,
+ __rawRequest: requestPayload,
+ __rawResponse: response,
+ reaction: undefined,
+ openFeedback: openFeedbackPanel,
+ logFeedbackVote,
+ },
+ ...current,
+ ]);
+ }
+ },
+ [
+ getUpdatedWorkflow,
+ signal,
+ autoApply,
+ onWorkflowProposed,
+ getNodeVisuals,
+ onNodeClick,
+ openFeedbackPanel,
+ logFeedbackVote,
+ enrichChangesWithVisuals,
+ scheduleIconEnrichment,
+ intlText.workflowAppliedText,
+ intlText.workflowProposedText,
+ intlText.workflowUndoneText,
+ ]
+ );
+
+ const handleChatbotResponse = useCallback(
+ async (query: string, requestPayload: RequestData) => {
+ const response = await chatbotService.getCopilotResponse(query, await getUpdatedWorkflow(), signal, await getAuthToken());
+ if (!isSuccessResponse(response.status)) {
+ throw new Error(response.statusText);
+ }
+ const queryResponse: string = response.data.properties.response;
+ // commenting out usage of additionalParameters until Logic Apps backend is updated to include this response property
+ const additionalParameters: AdditionalParametersItem = response.data.properties.additionalParameters;
+ setConversation((current) => [
+ {
+ type: ConversationItemType.Reply,
+ id: response.data.properties.queryId,
+ date: new Date(),
+ text: queryResponse,
+ isMarkdownText: false,
+ correlationId: chatSessionId.current,
+ __rawRequest: requestPayload,
+ __rawResponse: response,
+ reaction: undefined,
+ additionalDocURL: additionalParameters?.url ?? undefined,
+ azureButtonCallback:
+ /*additionalParameters?.includes(constants.WorkflowResponseAdditionalParameters.SendToAzure)*/ queryResponse ===
+ constants.DefaultAzureResponseCallback && openAzureCopilotPanel
+ ? () => openAzureCopilotPanel(query)
+ : undefined,
+ openFeedback: openFeedbackPanel,
+ logFeedbackVote,
+ },
+ ...current,
+ ]);
+ },
+ [chatbotService, getUpdatedWorkflow, signal, getAuthToken, openAzureCopilotPanel, openFeedbackPanel, logFeedbackVote]
+ );
+
const onSubmitInputQuery = useCallback(
async (input: string) => {
const query = input.trim();
@@ -209,35 +588,11 @@ export const CoPilotChatbot = ({
};
stopAnswerGeneration(false);
try {
- const response = await chatbotService.getCopilotResponse(query, await getUpdatedWorkflow(), signal, await getAuthToken());
- if (!isSuccessResponse(response.status)) {
- throw new Error(response.statusText);
+ if (workflowEditorEnabled) {
+ await handleWorkflowEditResponse(query, requestPayload);
+ } else {
+ await handleChatbotResponse(query, requestPayload);
}
- const queryResponse: string = response.data.properties.response;
- // commenting out usage of additionalParameters until Logic Apps backend is updated to include this response property
- const additionalParameters: AdditionalParametersItem = response.data.properties.additionalParameters;
- setConversation((current) => [
- {
- type: ConversationItemType.Reply,
- id: response.data.properties.queryId,
- date: new Date(),
- text: queryResponse,
- isMarkdownText: false,
- correlationId: chatSessionId.current,
- __rawRequest: requestPayload,
- __rawResponse: response,
- reaction: undefined,
- additionalDocURL: additionalParameters?.url ?? undefined,
- azureButtonCallback:
- /*additionalParameters?.includes(constants.WorkflowResponseAdditionalParameters.SendToAzure)*/ queryResponse ===
- constants.DefaultAzureResponseCallback && openAzureCopilotPanel
- ? () => openAzureCopilotPanel(query)
- : undefined,
- openFeedback: openFeedbackPanel,
- logFeedbackVote,
- },
- ...current,
- ]);
stopAnswerGeneration(true);
setTimeout(() => {
setFocus(true);
@@ -294,10 +649,9 @@ export const CoPilotChatbot = ({
},
[
getUpdatedWorkflow,
- chatbotService,
- signal,
- getAuthToken,
- openAzureCopilotPanel,
+ workflowEditorEnabled,
+ handleWorkflowEditResponse,
+ handleChatbotResponse,
openFeedbackPanel,
logFeedbackVote,
intlText.cancelGenerationText,
@@ -306,7 +660,6 @@ export const CoPilotChatbot = ({
);
const dismissCopilot = useCallback(() => {
- setCollapsed(true);
closeChatBot?.();
LoggerService().log({
level: LogEntryLevel.Warning,
@@ -319,22 +672,16 @@ export const CoPilotChatbot = ({
controller.abort();
}, [controller]);
- useEffect(() => {
- setInputQuery('');
- }, [conversation]);
-
return (
,
+ header: ,
}}
inputBox={{
- value: inputQuery,
- onChange: setInputQuery,
placeholder: intlText.chatInputPlaceholder,
onSubmit: onSubmitInputQuery,
}}
diff --git a/libs/chatbot/src/lib/ui/__test__/ChatbotUi.spec.tsx b/libs/chatbot/src/lib/ui/__test__/ChatbotUi.spec.tsx
new file mode 100644
index 00000000000..20f00881bdf
--- /dev/null
+++ b/libs/chatbot/src/lib/ui/__test__/ChatbotUi.spec.tsx
@@ -0,0 +1,327 @@
+/**
+ * @vitest-environment jsdom
+ */
+import React from 'react';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { describe, vi, beforeEach, afterEach, it, expect } from 'vitest';
+import { FluentProvider, webLightTheme } from '@fluentui/react-components';
+import { ChatbotUI, AssistantChat, defaultChatbotPanelWidth } from '../ChatbotUi';
+import { mockUseIntl } from '../../__test__/intl-test-helper';
+import { ConversationItemType, FlowOrigin, PanelLocation } from '@microsoft/designer-ui';
+import type { ConversationItem } from '@microsoft/designer-ui';
+
+// Mock designer-ui components to simplify rendering
+vi.mock('@microsoft/designer-ui', async (importOriginal) => {
+ const actual = (await importOriginal()) as Record;
+ return {
+ ...actual,
+ ChatInput: React.forwardRef(({ query, onQueryChange, placeholder, disabled, submitButtonProps }: any, ref: any) => (
+
+ onQueryChange?.(e, e.target.value)}
+ placeholder={placeholder}
+ disabled={disabled}
+ />
+
+ {submitButtonProps?.title}
+
+
+ )),
+ ChatSuggestion: ({ text, onClick }: any) => (
+
+ {text}
+
+ ),
+ ChatSuggestionGroup: ({ children }: any) => {children}
,
+ ConversationMessage: ({ item }: any) => {item.text}
,
+ ProgressCardWithStopButton: ({ progressState, onStopButtonClick }: any) => (
+
+ {progressState}
+ {onStopButtonClick && (
+
+ Stop
+
+ )}
+
+ ),
+ };
+});
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render({ui} );
+};
+
+const createDefaultProps = (overrides: Record = {}) => ({
+ panel: {
+ width: '360px',
+ location: PanelLocation.Left,
+ isOpen: true,
+ onDismiss: vi.fn(),
+ header: Header
,
+ ...overrides.panel,
+ },
+ inputBox: {
+ onSubmit: vi.fn(),
+ ...overrides.inputBox,
+ },
+ data: {
+ isSaving: false,
+ canSave: false,
+ canTest: false,
+ test: vi.fn(),
+ save: vi.fn(),
+ abort: vi.fn(),
+ ...overrides.data,
+ },
+ string: {
+ progressState: 'Working...',
+ progressSave: 'Saving...',
+ ...overrides.string,
+ },
+ body: {
+ messages: [] as ConversationItem[],
+ focus: false,
+ answerGenerationInProgress: false,
+ setFocus: vi.fn(),
+ ...overrides.body,
+ },
+});
+
+describe('ui/ChatbotUi/ChatbotUI', () => {
+ beforeEach(() => {
+ mockUseIntl();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render the container', () => {
+ const props = createDefaultProps();
+ const { container } = renderWithProviders( );
+ expect(container.firstChild).toBeDefined();
+ });
+
+ it('should render conversation messages', () => {
+ const messages: ConversationItem[] = [
+ { type: ConversationItemType.Query, id: 'msg-1', date: new Date(), text: 'Hello' },
+ { type: ConversationItemType.Reply, id: 'msg-2', date: new Date(), text: 'Hi there' },
+ ];
+ const props = createDefaultProps({ body: { messages, focus: false, answerGenerationInProgress: false, setFocus: vi.fn() } });
+ renderWithProviders( );
+
+ expect(screen.getByTestId('message-msg-1')).toBeDefined();
+ expect(screen.getByTestId('message-msg-2')).toBeDefined();
+ });
+
+ it('should render the chat input when not readOnly', () => {
+ const props = createDefaultProps();
+ renderWithProviders( );
+ expect(screen.getByTestId('chat-input')).toBeDefined();
+ });
+
+ it('should not render chat input when readOnly', () => {
+ const props = createDefaultProps({ inputBox: { onSubmit: vi.fn(), readOnly: true } });
+ renderWithProviders( );
+ expect(screen.queryByTestId('chat-input')).toBeNull();
+ });
+ });
+
+ describe('progress indicators', () => {
+ it('should show progress card when answer generation is in progress', () => {
+ const props = createDefaultProps({
+ body: { messages: [], focus: false, answerGenerationInProgress: true, setFocus: vi.fn() },
+ });
+ renderWithProviders( );
+ expect(screen.getByTestId('progress-card')).toBeDefined();
+ expect(screen.getByText('Working...')).toBeDefined();
+ });
+
+ it('should show saving progress card when isSaving is true', () => {
+ const props = createDefaultProps({
+ data: { isSaving: true, canSave: false, canTest: false },
+ });
+ renderWithProviders( );
+ expect(screen.getByText('Saving...')).toBeDefined();
+ });
+
+ it('should not show progress card when not generating', () => {
+ const props = createDefaultProps();
+ renderWithProviders( );
+ expect(screen.queryByTestId('progress-card')).toBeNull();
+ });
+ });
+
+ describe('submit behavior', () => {
+ it('should disable submit when input is too short', () => {
+ const props = createDefaultProps();
+ renderWithProviders( );
+
+ const submitButton = screen.getByTestId('submit-button');
+ expect(submitButton.getAttribute('disabled')).not.toBeNull();
+ });
+
+ it('should enable submit when input has enough characters', () => {
+ const props = createDefaultProps();
+ renderWithProviders( );
+
+ const input = screen.getByTestId('chat-input-field');
+ fireEvent.change(input, { target: { value: 'Hello world' } });
+
+ const submitButton = screen.getByTestId('submit-button');
+ expect(submitButton.getAttribute('disabled')).toBeNull();
+ });
+
+ it('should call onSubmit and clear input on submit', () => {
+ const onSubmit = vi.fn();
+ const props = createDefaultProps({ inputBox: { onSubmit } });
+ renderWithProviders( );
+
+ const input = screen.getByTestId('chat-input-field');
+ fireEvent.change(input, { target: { value: 'Test query here' } });
+ fireEvent.click(screen.getByTestId('submit-button'));
+
+ expect(onSubmit).toHaveBeenCalledWith('Test query here');
+ });
+
+ it('should disable submit when answer generation is in progress', () => {
+ const props = createDefaultProps({
+ body: { messages: [], focus: false, answerGenerationInProgress: true, setFocus: vi.fn() },
+ });
+ renderWithProviders( );
+
+ const submitButton = screen.getByTestId('submit-button');
+ expect(submitButton.getAttribute('disabled')).not.toBeNull();
+ });
+
+ it('should disable input when answer generation is in progress', () => {
+ const props = createDefaultProps({
+ body: { messages: [], focus: false, answerGenerationInProgress: true, setFocus: vi.fn() },
+ });
+ renderWithProviders( );
+
+ const input = screen.getByTestId('chat-input-field');
+ expect(input.getAttribute('disabled')).not.toBeNull();
+ });
+
+ it('should disable input when disabled prop is true', () => {
+ const props = createDefaultProps({ inputBox: { onSubmit: vi.fn(), disabled: true } });
+ renderWithProviders( );
+
+ const input = screen.getByTestId('chat-input-field');
+ expect(input.getAttribute('disabled')).not.toBeNull();
+ });
+ });
+
+ describe('suggestions', () => {
+ it('should show save suggestion when canSave is true', () => {
+ const props = createDefaultProps({ data: { canSave: true, canTest: false } });
+ renderWithProviders( );
+ expect(screen.getByTestId('suggestion-group')).toBeDefined();
+ });
+
+ it('should show test suggestion when canTest is true', () => {
+ const props = createDefaultProps({ data: { canSave: false, canTest: true } });
+ renderWithProviders( );
+ expect(screen.getByTestId('suggestion-group')).toBeDefined();
+ });
+
+ it('should not show suggestions when canSave and canTest are false', () => {
+ const props = createDefaultProps({ data: { canSave: false, canTest: false } });
+ renderWithProviders( );
+ expect(screen.queryByTestId('suggestion-group')).toBeNull();
+ });
+
+ it('should call save callback when save suggestion is clicked', () => {
+ const save = vi.fn();
+ const props = createDefaultProps({ data: { canSave: true, canTest: false, save } });
+ renderWithProviders( );
+
+ const saveButton = screen.getByText('Save this workflow');
+ fireEvent.click(saveButton);
+ expect(save).toHaveBeenCalled();
+ });
+
+ it('should call test callback when test suggestion is clicked', () => {
+ const test = vi.fn();
+ const props = createDefaultProps({ data: { canSave: false, canTest: true, test } });
+ renderWithProviders( );
+
+ const testButton = screen.getByText('Test this workflow');
+ fireEvent.click(testButton);
+ expect(test).toHaveBeenCalled();
+ });
+ });
+
+ describe('protected message', () => {
+ it('should show protected message when provided', () => {
+ const props = createDefaultProps({
+ string: { progressState: 'Working...', progressSave: 'Saving...', protectedMessage: 'Data is protected' },
+ });
+ renderWithProviders( );
+ expect(screen.getByText('Data is protected')).toBeDefined();
+ });
+
+ it('should not show protected message when not provided', () => {
+ const props = createDefaultProps();
+ renderWithProviders( );
+ expect(screen.queryByText('Data is protected')).toBeNull();
+ });
+ });
+
+ describe('stop button', () => {
+ it('should call abort when stop button is clicked during generation', () => {
+ const abort = vi.fn();
+ const props = createDefaultProps({
+ data: { abort },
+ body: { messages: [], focus: false, answerGenerationInProgress: true, setFocus: vi.fn() },
+ });
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId('stop-button'));
+ expect(abort).toHaveBeenCalled();
+ });
+ });
+});
+
+describe('ui/ChatbotUi/AssistantChat', () => {
+ beforeEach(() => {
+ mockUseIntl();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('should render when isOpen is true', () => {
+ const props = createDefaultProps({ panel: { isOpen: true } });
+ renderWithProviders( );
+ // The InlineDrawer should be rendered
+ expect(screen.getByText('Header')).toBeDefined();
+ });
+
+ it('should render with default panel width', () => {
+ expect(defaultChatbotPanelWidth).toBe('360px');
+ });
+
+ it('should render close button with proper label', () => {
+ const props = createDefaultProps({ panel: { isOpen: true, onDismiss: vi.fn(), header: Header
} });
+ renderWithProviders( );
+ expect(screen.getByLabelText('Close')).toBeDefined();
+ });
+
+ it('should call onDismiss when close button is clicked', () => {
+ const onDismiss = vi.fn();
+ const props = createDefaultProps({ panel: { isOpen: true, onDismiss, header: Header
} });
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByLabelText('Close'));
+ expect(onDismiss).toHaveBeenCalled();
+ });
+});
diff --git a/libs/chatbot/src/lib/ui/__test__/CopilotChatbot.spec.tsx b/libs/chatbot/src/lib/ui/__test__/CopilotChatbot.spec.tsx
new file mode 100644
index 00000000000..a05135a4999
--- /dev/null
+++ b/libs/chatbot/src/lib/ui/__test__/CopilotChatbot.spec.tsx
@@ -0,0 +1,318 @@
+/**
+ * @vitest-environment jsdom
+ */
+import React from 'react';
+import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
+import { describe, vi, beforeEach, afterEach, it, expect } from 'vitest';
+import { FluentProvider, webLightTheme } from '@fluentui/react-components';
+import { CoPilotChatbot } from '../CopilotChatbot';
+import { mockUseIntl } from '../../__test__/intl-test-helper';
+import { PanelLocation, ConversationItemType } from '@microsoft/designer-ui';
+
+// Capture props passed to AssistantChat so we can assert on them
+let capturedAssistantChatProps: Record = {};
+
+vi.mock('../ChatbotUi', () => ({
+ defaultChatbotPanelWidth: '360px',
+ AssistantChat: (props: any) => {
+ capturedAssistantChatProps = props;
+ return (
+
+
props.inputBox.onSubmit('test query message')}>
+ Submit
+
+ {props.body.messages.map((msg: any, idx: number) => (
+
+ {msg.text ?? msg.error ?? ''}
+
+ ))}
+
+ );
+ },
+}));
+
+vi.mock('../panelheader', () => ({
+ CopilotPanelHeader: () => Header
,
+}));
+
+// Mock services
+const mockGetCopilotResponse = vi.fn();
+const mockGetWorkflowEdit = vi.fn();
+
+vi.mock('@microsoft/logic-apps-shared', async (importOriginal) => {
+ const actual = (await importOriginal()) as Record;
+ return {
+ ...actual,
+ ChatbotService: () => ({
+ getCopilotResponse: mockGetCopilotResponse,
+ }),
+ CopilotWorkflowEditorService: () => ({
+ getWorkflowEdit: mockGetWorkflowEdit,
+ }),
+ isCopilotWorkflowEditorServiceInitialized: () => false,
+ LoggerService: () => ({
+ log: vi.fn(),
+ }),
+ guid: () => `test-guid-${Date.now()}`,
+ fallbackConnectorIconUrl: () => 'https://fallback-icon.png',
+ };
+});
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render({ui} );
+};
+
+const mockWorkflow = {
+ definition: { $schema: 'test', contentVersion: '1.0', triggers: {}, actions: {} },
+ connectionReferences: {},
+ parameters: {},
+ kind: 'Stateful' as const,
+};
+
+const defaultProps = {
+ getAuthToken: vi.fn().mockResolvedValue('test-token'),
+ getUpdatedWorkflow: vi.fn().mockResolvedValue(mockWorkflow),
+ openFeedbackPanel: vi.fn(),
+ closeChatBot: vi.fn(),
+};
+
+describe('ui/CopilotChatbot', () => {
+ beforeEach(() => {
+ mockUseIntl();
+ capturedAssistantChatProps = {};
+ mockGetCopilotResponse.mockReset();
+ mockGetWorkflowEdit.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render the AssistantChat component', () => {
+ renderWithProviders( );
+ expect(screen.getByTestId('assistant-chat')).toBeDefined();
+ });
+
+ it('should render with default panel location (Left)', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.panel.location).toBe(PanelLocation.Left);
+ });
+
+ it('should render with custom panel location', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.panel.location).toBe(PanelLocation.Right);
+ });
+
+ it('should render with custom chatbot width', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.panel.width).toBe('500px');
+ });
+
+ it('should pass isOpen prop to AssistantChat', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.panel.isOpen).toBe(false);
+ });
+
+ it('should start with a greeting message', () => {
+ renderWithProviders( );
+ const messages = capturedAssistantChatProps.body.messages;
+ expect(messages).toHaveLength(1);
+ expect(messages[0].type).toBe(ConversationItemType.Greeting);
+ });
+
+ it('should include protected message in string props', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.string.protectedMessage).toBeDefined();
+ expect(typeof capturedAssistantChatProps.string.protectedMessage).toBe('string');
+ });
+ });
+
+ describe('chatbot response flow', () => {
+ it('should add user query to conversation on submit', async () => {
+ mockGetCopilotResponse.mockResolvedValue({
+ status: 200,
+ data: { properties: { queryId: 'q1', response: 'Test response' } },
+ });
+
+ renderWithProviders( );
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('submit-trigger'));
+ });
+
+ await waitFor(() => {
+ const messages = capturedAssistantChatProps.body.messages;
+ const queryMessage = messages.find((m: any) => m.type === ConversationItemType.Query);
+ expect(queryMessage).toBeDefined();
+ expect(queryMessage.text).toBe('test query message');
+ });
+ });
+
+ it('should add reply to conversation on successful response', async () => {
+ mockGetCopilotResponse.mockResolvedValue({
+ status: 200,
+ data: { properties: { queryId: 'q1', response: 'AI response here' } },
+ });
+
+ renderWithProviders( );
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('submit-trigger'));
+ });
+
+ await waitFor(() => {
+ const messages = capturedAssistantChatProps.body.messages;
+ const replyMessage = messages.find((m: any) => m.type === ConversationItemType.Reply);
+ expect(replyMessage).toBeDefined();
+ expect(replyMessage.text).toBe('AI response here');
+ });
+ });
+
+ it('should show error message on failed response', async () => {
+ mockGetCopilotResponse.mockRejectedValue(new Error('API failure'));
+
+ renderWithProviders( );
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('submit-trigger'));
+ });
+
+ await waitFor(() => {
+ const messages = capturedAssistantChatProps.body.messages;
+ const errorMessage = messages.find((m: any) => m.type === ConversationItemType.ReplyError);
+ expect(errorMessage).toBeDefined();
+ });
+ });
+
+ it('should not submit empty query', async () => {
+ // Use the actual AssistantChat mock which passes '' to onSubmit
+ renderWithProviders( );
+
+ const onSubmit = capturedAssistantChatProps.inputBox.onSubmit;
+ await act(async () => {
+ await onSubmit('');
+ });
+
+ // Should still only have the greeting message
+ expect(capturedAssistantChatProps.body.messages).toHaveLength(1);
+ expect(mockGetCopilotResponse).not.toHaveBeenCalled();
+ });
+
+ it('should not submit whitespace-only query', async () => {
+ renderWithProviders( );
+
+ const onSubmit = capturedAssistantChatProps.inputBox.onSubmit;
+ await act(async () => {
+ await onSubmit(' ');
+ });
+
+ expect(capturedAssistantChatProps.body.messages).toHaveLength(1);
+ expect(mockGetCopilotResponse).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('dismiss behavior', () => {
+ it('should call closeChatBot on dismiss', () => {
+ const closeChatBot = vi.fn();
+ renderWithProviders( );
+
+ capturedAssistantChatProps.panel.onDismiss();
+ expect(closeChatBot).toHaveBeenCalled();
+ });
+ });
+
+ describe('answer generation state', () => {
+ it('should start with answerGenerationInProgress as false', () => {
+ renderWithProviders( );
+ // answerGeneration starts as true, so !answerGeneration = false
+ expect(capturedAssistantChatProps.body.answerGenerationInProgress).toBe(false);
+ });
+
+ it('should return to answerGenerationInProgress false after response', async () => {
+ mockGetCopilotResponse.mockResolvedValue({
+ status: 200,
+ data: { properties: { queryId: 'q1', response: 'done' } },
+ });
+
+ renderWithProviders( );
+
+ await act(async () => {
+ await capturedAssistantChatProps.inputBox.onSubmit('test query');
+ });
+
+ await waitFor(() => {
+ expect(capturedAssistantChatProps.body.answerGenerationInProgress).toBe(false);
+ });
+ });
+ });
+
+ describe('workflow editing placeholder', () => {
+ it('should use workflow editing placeholder when enableWorkflowEditing is true', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.inputBox).toBeDefined();
+ const placeholder = capturedAssistantChatProps.inputBox.placeholder;
+ expect(placeholder).toBeDefined();
+ expect(typeof placeholder).toBe('string');
+ });
+
+ it('should use standard placeholder when enableWorkflowEditing is false', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.inputBox).toBeDefined();
+ const placeholder = capturedAssistantChatProps.inputBox.placeholder;
+ expect(placeholder).toBeDefined();
+ expect(typeof placeholder).toBe('string');
+ });
+ });
+
+ describe('greeting message', () => {
+ it('should include workflowEditingEnabled in greeting based on service availability', () => {
+ renderWithProviders( );
+ expect(capturedAssistantChatProps.body).toBeDefined();
+ const messages = capturedAssistantChatProps.body.messages;
+ const greeting = messages.find((m: any) => m.type === ConversationItemType.Greeting);
+ expect(greeting).toBeDefined();
+ // Since isCopilotWorkflowEditorServiceInitialized returns false, editing not enabled
+ expect(greeting.workflowEditingEnabled).toBe(false);
+ });
+ });
+
+ describe('conversation history persistence', () => {
+ it('should preserve conversation history when isOpen toggles', async () => {
+ mockGetCopilotResponse.mockResolvedValue({
+ status: 200,
+ data: { properties: { queryId: 'q1', response: 'response 1' } },
+ });
+
+ const { rerender } = renderWithProviders( );
+
+ // Submit a query
+ await act(async () => {
+ await capturedAssistantChatProps.inputBox.onSubmit('hello');
+ });
+
+ await waitFor(() => {
+ const messages = capturedAssistantChatProps.body.messages;
+ expect(messages.length).toBeGreaterThan(1);
+ });
+
+ const messageCountBeforeClose = capturedAssistantChatProps.body.messages.length;
+
+ // Close and reopen — component stays mounted
+ rerender(
+
+
+
+ );
+ rerender(
+
+
+
+ );
+
+ // Messages should be preserved
+ expect(capturedAssistantChatProps.body.messages.length).toBe(messageCountBeforeClose);
+ });
+ });
+});
diff --git a/libs/chatbot/src/lib/ui/__test__/panelheader.spec.tsx b/libs/chatbot/src/lib/ui/__test__/panelheader.spec.tsx
index 7b10633632e..20726d3f3c2 100644
--- a/libs/chatbot/src/lib/ui/__test__/panelheader.spec.tsx
+++ b/libs/chatbot/src/lib/ui/__test__/panelheader.spec.tsx
@@ -3,37 +3,25 @@
*/
import React from 'react';
import { CopilotPanelHeader } from '../panelheader';
-import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { render, screen, cleanup } from '@testing-library/react';
import { describe, vi, beforeEach, afterEach, it, expect } from 'vitest';
-import { ThemeProvider } from '@fluentui/react';
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components';
import { mockUseIntl } from '../../__test__/intl-test-helper';
-import { initializeIcons } from '@fluentui/react';
import { InitLoggerService } from '@microsoft/logic-apps-shared';
-// Initialize icons and logger for tests
-initializeIcons();
+// Initialize logger for tests
InitLoggerService([]);
// Helper to render with required providers
const renderWithProviders = (ui: React.ReactElement, { isDarkTheme = false }: { isDarkTheme?: boolean } = {}) => {
const fluentTheme = isDarkTheme ? webDarkTheme : webLightTheme;
- const v8Theme = { isInverted: isDarkTheme };
- return render(
-
- {ui}
-
- );
+ return render({ui} );
};
describe('ui/panelheader/CopilotPanelHeader', () => {
- let closeCopilotMock: ReturnType;
-
beforeEach(() => {
mockUseIntl();
- closeCopilotMock = vi.fn();
- vi.spyOn(window, 'open').mockImplementation(() => null);
});
afterEach(() => {
@@ -43,7 +31,7 @@ describe('ui/panelheader/CopilotPanelHeader', () => {
describe('rendering', () => {
it('should render the header with all elements', () => {
- renderWithProviders( );
+ renderWithProviders( );
// Check header title is rendered
expect(screen.getByText('Workflow assistant')).toBeDefined();
@@ -53,56 +41,41 @@ describe('ui/panelheader/CopilotPanelHeader', () => {
// Check protected pill is rendered
expect(screen.getByText('Protected')).toBeDefined();
-
- // Check close button is rendered
- expect(screen.getByTitle('Close')).toBeDefined();
});
it('should render the Logic Apps icon', () => {
- renderWithProviders( );
+ renderWithProviders( );
const icon = screen.getByAltText('Logic Apps');
expect(icon).toBeDefined();
});
it('should render correctly in light theme', () => {
- const { container } = renderWithProviders( , { isDarkTheme: false });
+ const { container } = renderWithProviders( , { isDarkTheme: false });
expect(container.firstChild).toBeDefined();
});
it('should render correctly in dark theme', () => {
- const { container } = renderWithProviders( , { isDarkTheme: true });
+ const { container } = renderWithProviders( , { isDarkTheme: true });
expect(container.firstChild).toBeDefined();
});
});
- describe('close button', () => {
- it('should call closeCopilot when close button is clicked', () => {
- renderWithProviders( );
-
- const closeButton = screen.getByTitle('Close');
- fireEvent.click(closeButton);
-
- expect(closeCopilotMock).toHaveBeenCalledTimes(1);
- });
- });
-
describe('protected link', () => {
- it('should open privacy statement in new tab when Protected link is clicked', () => {
- renderWithProviders( );
+ it('should have privacy statement link with correct href', () => {
+ renderWithProviders( );
const protectedLink = screen.getByText('Protected');
- fireEvent.click(protectedLink);
-
- expect(window.open).toHaveBeenCalledWith('https://aka.ms/azurecopilot/privacystatement', '_blank');
+ expect(protectedLink.closest('a')?.getAttribute('href')).toBe('https://aka.ms/azurecopilot/privacystatement');
+ expect(protectedLink.closest('a')?.getAttribute('target')).toBe('_blank');
});
});
describe('tooltip', () => {
it('should have a tooltip on the protected pill', () => {
- renderWithProviders( );
+ renderWithProviders( );
// The tooltip wrapper should exist around the protected pill
const protectedPill = screen.getByText('Protected').closest('div');
@@ -111,49 +84,26 @@ describe('ui/panelheader/CopilotPanelHeader', () => {
});
describe('accessibility', () => {
- it('should have accessible close button with title attribute', () => {
- renderWithProviders( );
-
- const closeButton = screen.getByTitle('Close');
- expect(closeButton).toBeDefined();
- expect(closeButton.tagName.toLowerCase()).toBe('button');
- });
-
it('should have accessible alt text for the Logic Apps icon', () => {
- renderWithProviders( );
+ renderWithProviders( );
const icon = screen.getByAltText('Logic Apps');
expect(icon.tagName.toLowerCase()).toBe('img');
});
it('should use semantic h2 heading for the header title', () => {
- renderWithProviders( );
+ renderWithProviders( );
const heading = screen.getByRole('heading', { level: 2, name: 'Workflow assistant' });
expect(heading).toBeDefined();
});
it('should have aria-label on protected pill tooltip for screen readers', () => {
- renderWithProviders( );
+ renderWithProviders( );
// The tooltip has aria-label with the full protection message
const tooltipTrigger = screen.getByLabelText('Your personal and company data are protected in this chat');
expect(tooltipTrigger).toBeDefined();
});
-
- it('should use semantic button element for close action', () => {
- renderWithProviders( );
-
- const closeButton = screen.getByTitle('Close');
- expect(closeButton.getAttribute('type')).toBe('button');
- });
-
- it('should have focusable close button', () => {
- renderWithProviders( );
-
- const closeButton = screen.getByTitle('Close');
- // data-is-focusable is used by Fluent UI for focus management
- expect(closeButton.getAttribute('data-is-focusable')).toBe('true');
- });
});
});
diff --git a/libs/chatbot/src/lib/ui/panelheader.tsx b/libs/chatbot/src/lib/ui/panelheader.tsx
index c4d619099e3..5357c36740a 100644
--- a/libs/chatbot/src/lib/ui/panelheader.tsx
+++ b/libs/chatbot/src/lib/ui/panelheader.tsx
@@ -1,23 +1,15 @@
import Workflow from '../images/Workflow.svg';
-import { FontSizes, Link, useTheme } from '@fluentui/react';
-import { Tooltip, mergeClasses, Subtitle2 } from '@fluentui/react-components';
+import { Badge, Link, Tooltip, Subtitle2 } from '@fluentui/react-components';
import { ShieldCheckmarkRegular } from '@fluentui/react-icons';
-import { IconButton } from '@fluentui/react/lib/Button';
import { LogEntryLevel, LoggerService } from '@microsoft/logic-apps-shared';
import { useIntl } from 'react-intl';
-import { useChatbotStyles, useChatbotDarkStyles } from './styles';
+import { useChatbotStyles } from './styles';
-interface CopilotPanelHeaderProps {
- closeCopilot: () => void;
-}
-
-export const CopilotPanelHeader = ({ closeCopilot }: CopilotPanelHeaderProps): JSX.Element => {
+export const CopilotPanelHeader = (): JSX.Element => {
const intl = useIntl();
- const { isInverted } = useTheme();
// Styles
const styles = useChatbotStyles();
- const darkStyles = useChatbotDarkStyles();
const headerTitle = intl.formatMessage({
defaultMessage: 'Workflow assistant',
@@ -39,11 +31,6 @@ export const CopilotPanelHeader = ({ closeCopilot }: CopilotPanelHeaderProps): J
id: 'Yrw/Qt',
description: 'Letting user know that their data is protected in the chatbot',
});
- const closeButtonTitle = intl.formatMessage({
- defaultMessage: 'Close',
- id: 'ZihyUf',
- description: 'Label for the close button in the chatbot header',
- });
return (
@@ -52,38 +39,28 @@ export const CopilotPanelHeader = ({ closeCopilot }: CopilotPanelHeaderProps): J
{headerTitle}
- {subtitleText}
-
-
-
-
-
- {
- window.open('https://aka.ms/azurecopilot/privacystatement', '_blank');
- LoggerService().log({
- level: LogEntryLevel.Warning,
- area: 'chatbot',
- message: 'protection link opened',
- });
- }}
- underline={true}
- >
- {protectedPillText}
-
-
-
+
{subtitleText}
- {
- closeCopilot();
- }}
- />
+
+
+ {
+ LoggerService().log({
+ level: LogEntryLevel.Warning,
+ area: 'chatbot',
+ message: 'protection link opened',
+ });
+ }}
+ >
+ }>
+ {protectedPillText}
+
+
+
);
};
diff --git a/libs/chatbot/src/lib/ui/styles.ts b/libs/chatbot/src/lib/ui/styles.ts
index 17cd6c0ee46..f9b929a0804 100644
--- a/libs/chatbot/src/lib/ui/styles.ts
+++ b/libs/chatbot/src/lib/ui/styles.ts
@@ -23,7 +23,6 @@ export const useChatbotStyles = makeStyles({
header: {
display: 'flex',
flexDirection: 'row',
- ...shorthands.padding(tokens.spacingVerticalM),
alignItems: 'center',
fontSize: tokens.fontSizeBase600,
fontWeight: tokens.fontWeightBold,
@@ -37,68 +36,35 @@ export const useChatbotStyles = makeStyles({
headerIcon: {
color: '#2899f5', // Keep original brand blue
transform: 'scale(1.5)',
- ...shorthands.margin(0, 0, '5px', '5px'),
+ width: '32px',
+ height: '32px',
+ margin: 0,
},
headerTitle: {
fontWeight: tokens.fontWeightSemibold,
fontSize: tokens.fontSizeBase400,
- lineHeight: '28px',
- ...shorthands.margin(0, tokens.spacingHorizontalM),
+ lineHeight: '24px',
+ margin: 0,
},
headerSubtitle: {
- marginLeft: tokens.spacingHorizontalM,
width: 'fit-content',
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightRegular,
- borderRadius: tokens.borderRadiusCircular,
lineHeight: '15px',
color: tokens.colorNeutralForeground2,
display: 'block',
},
- // Mode pill styles
- headerModePill: {
- ...shorthands.margin('0', tokens.spacingHorizontalXS),
- width: 'fit-content',
- fontSize: tokens.fontSizeBase200,
- fontWeight: tokens.fontWeightSemibold,
- textTransform: 'uppercase',
- color: tokens.colorNeutralForeground2,
- backgroundColor: tokens.colorNeutralBackground2,
- ...shorthands.borderRadius(tokens.borderRadiusCircular),
- ...shorthands.padding('3.5px', tokens.spacingHorizontalS),
- lineHeight: '15px',
- },
-
shieldCheckmarkRegular: {
- paddingRight: '2px',
+ paddingRight: '4px',
},
- headerModeProtectedPill: {
- ...shorthands.margin('0', tokens.spacingHorizontalXS),
- width: 'fit-content',
- fontSize: tokens.fontSizeBase200,
- display: 'flex',
- alignItems: 'center',
- fontWeight: tokens.fontWeightSemibold,
- color: tokens.colorNeutralForegroundInverted,
- backgroundColor: tokens.colorPaletteGreenBorderActive,
- borderRadius: tokens.borderRadiusCircular,
- ...shorthands.padding('3px', tokens.spacingHorizontalS),
- lineHeight: '10px',
- },
-
- protectedMessageLink: {
- color: tokens.colorNeutralForegroundInverted,
- '&:hover': {
- color: tokens.colorNeutralForegroundInverted,
- },
- '&:focus': {
- color: `${tokens.colorNeutralForegroundInverted} !important`,
- outlineColor: 'transparent !important',
- },
+ protectedBadgeLink: {
+ textDecorationLine: 'none',
+ marginLeft: '16px',
+ lineHeight: '12px',
},
collapseButton: {
diff --git a/libs/designer-ui/src/lib/chatbot/chatbot.less b/libs/designer-ui/src/lib/chatbot/chatbot.less
index 2cb0a996975..c1b9a2092e7 100644
--- a/libs/designer-ui/src/lib/chatbot/chatbot.less
+++ b/libs/designer-ui/src/lib/chatbot/chatbot.less
@@ -35,6 +35,9 @@
width: 100%;
border-radius: 10px;
text-align: start;
+ display: flex;
+ gap: 6px;
+ flex-direction: column;
@-moz-document url-prefix() {
li {
margin-left: 12px;
@@ -47,6 +50,7 @@
}
.msla-bubble-footer {
+ margin-top: 8px;
gap: 4px;
flex-direction: column;
}
@@ -108,26 +112,11 @@
}
}
-.msla-bubble-actions-footer {
- display: flex;
- flex-direction: row;
- justify-content: flex-start;
- align-items: center;
-}
-
.msla-bubble-reactions {
flex-grow: 2;
text-align: right;
}
-.msla-copy-button {
- color: #323130;
-}
-
-.msla-copy-button:hover {
- color: #323130;
-}
-
.msla-assistant-error-container {
border: 1px solid rgb(237, 235, 233);
border-radius: 10px;
@@ -451,8 +440,4 @@
.msla-bubble-footer-role {
color: #a9a9a9;
}
-
- .msla-copy-button {
- color: #f3f2f1;
- }
}
diff --git a/libs/designer-ui/src/lib/chatbot/components/__test__/__snapshots__/assistantGreeting.spec.tsx.snap b/libs/designer-ui/src/lib/chatbot/components/__test__/__snapshots__/assistantGreeting.spec.tsx.snap
index 603bb027a25..b7b2dad571b 100644
--- a/libs/designer-ui/src/lib/chatbot/components/__test__/__snapshots__/assistantGreeting.spec.tsx.snap
+++ b/libs/designer-ui/src/lib/chatbot/components/__test__/__snapshots__/assistantGreeting.spec.tsx.snap
@@ -25,7 +25,7 @@ exports[`AssistantGreeting > snapshots > should match snapshot with dark theme 1
- Some things you can ask:
+ Some things you can try: