@cognizant-ai-lab/ui-common 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +3 -3
  2. package/dist/components/AgentChat/ChatCommon/ChatCommon.d.ts +9 -3
  3. package/dist/components/AgentChat/ChatCommon/ChatCommon.js +89 -23
  4. package/dist/components/AgentChat/ChatCommon/ChatHistory.d.ts +7 -0
  5. package/dist/components/AgentChat/ChatCommon/ChatHistory.js +1 -1
  6. package/dist/components/AgentChat/ChatCommon/ControlButtons.d.ts +4 -2
  7. package/dist/components/AgentChat/ChatCommon/ControlButtons.js +8 -2
  8. package/dist/components/AgentChat/ChatCommon/ConversationTurn.d.ts +5 -5
  9. package/dist/components/AgentChat/ChatCommon/ConversationTurn.js +1 -0
  10. package/dist/components/AgentChat/Common/Utils.d.ts +4 -0
  11. package/dist/components/AgentChat/Common/Utils.js +5 -1
  12. package/dist/components/Common/Breadcrumbs.js +1 -1
  13. package/dist/components/Common/notification.d.ts +4 -4
  14. package/dist/components/MultiAgentAccelerator/{AgentFlow.d.ts → AgentFlow/AgentFlow.d.ts} +15 -5
  15. package/dist/components/MultiAgentAccelerator/{AgentFlow.js → AgentFlow/AgentFlow.js} +44 -35
  16. package/dist/components/MultiAgentAccelerator/{AgentNode.d.ts → AgentFlow/AgentNode.d.ts} +1 -1
  17. package/dist/components/MultiAgentAccelerator/{AgentNode.js → AgentFlow/AgentNode.js} +4 -4
  18. package/dist/components/MultiAgentAccelerator/{GraphLayouts.d.ts → AgentFlow/GraphLayouts.d.ts} +4 -4
  19. package/dist/components/MultiAgentAccelerator/{GraphLayouts.js → AgentFlow/GraphLayouts.js} +6 -35
  20. package/dist/components/MultiAgentAccelerator/AgentFlow/GraphStructure.d.ts +21 -0
  21. package/dist/components/MultiAgentAccelerator/AgentFlow/GraphStructure.js +27 -0
  22. package/dist/components/MultiAgentAccelerator/AgentFlow/PlasmaEdge.d.ts +7 -0
  23. package/dist/components/MultiAgentAccelerator/{PlasmaEdge.js → AgentFlow/PlasmaEdge.js} +13 -8
  24. package/dist/components/MultiAgentAccelerator/MultiAgentAccelerator.js +116 -78
  25. package/dist/components/MultiAgentAccelerator/Schema/SlyData.d.ts +17 -0
  26. package/dist/components/MultiAgentAccelerator/Schema/SlyData.js +23 -0
  27. package/dist/components/MultiAgentAccelerator/Sidebar/AgentNetworkTreeItem.js +9 -4
  28. package/dist/components/MultiAgentAccelerator/Sidebar/ImportNetworkModal.d.ts +67 -0
  29. package/dist/components/MultiAgentAccelerator/Sidebar/ImportNetworkModal.js +585 -0
  30. package/dist/components/MultiAgentAccelerator/Sidebar/Sidebar.d.ts +2 -0
  31. package/dist/components/MultiAgentAccelerator/Sidebar/Sidebar.js +19 -26
  32. package/dist/components/MultiAgentAccelerator/Sidebar/TreeBuilder.d.ts +2 -1
  33. package/dist/components/MultiAgentAccelerator/Sidebar/TreeBuilder.js +24 -4
  34. package/dist/components/MultiAgentAccelerator/TemporaryNetworks.d.ts +22 -0
  35. package/dist/components/MultiAgentAccelerator/TemporaryNetworks.js +74 -3
  36. package/dist/components/MultiAgentAccelerator/{ThoughtBubbleEdge.d.ts → ThoughtBubbles/ThoughtBubbleEdge.d.ts} +1 -1
  37. package/dist/components/MultiAgentAccelerator/{ThoughtBubbleOverlay.js → ThoughtBubbles/ThoughtBubbleOverlay.js} +1 -1
  38. package/dist/components/MultiAgentAccelerator/Tour/MainTourSteps.js +5 -0
  39. package/dist/components/MultiAgentAccelerator/const.d.ts +1 -0
  40. package/dist/components/MultiAgentAccelerator/const.js +2 -0
  41. package/dist/components/Settings/SettingsDialog.js +26 -10
  42. package/dist/controller/agent/Agent.d.ts +14 -6
  43. package/dist/controller/agent/Agent.js +18 -14
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/state/Settings.d.ts +81 -4
  47. package/dist/state/Settings.js +87 -7
  48. package/dist/tsconfig.build.tsbuildinfo +1 -1
  49. package/dist/utils/BrowserNavigation.js +2 -0
  50. package/dist/utils/File.d.ts +4 -1
  51. package/dist/utils/File.js +4 -9
  52. package/dist/utils/text.js +3 -7
  53. package/package.json +9 -10
  54. package/dist/components/MultiAgentAccelerator/PlasmaEdge.d.ts +0 -3
  55. /package/dist/components/MultiAgentAccelerator/{ThoughtBubbleEdge.js → ThoughtBubbles/ThoughtBubbleEdge.js} +0 -0
  56. /package/dist/components/MultiAgentAccelerator/{ThoughtBubbleOverlay.d.ts → ThoughtBubbles/ThoughtBubbleOverlay.d.ts} +0 -0
@@ -0,0 +1,67 @@
1
+ import { FC } from "react";
2
+ import { AgentNetworkDefinitionEntry } from "../const.js";
3
+ export declare const IMPORT_MODAL_MAX_FILE_SIZE_BYTES: number;
4
+ export type ImportFileValidation = "valid" | "unsupported_type" | "too_large";
5
+ export interface ImportNetworkModalProps {
6
+ readonly existingNetworkNames?: readonly string[];
7
+ readonly isOpen: boolean;
8
+ readonly onClose: () => void;
9
+ readonly onImport?: (name: string, content: string) => void;
10
+ }
11
+ export interface NetworkSummary {
12
+ readonly agents: number;
13
+ readonly codedTools: number;
14
+ readonly externalAgents: number;
15
+ readonly frontman: string;
16
+ }
17
+ /**
18
+ * Parse and validate a network definition file. Imports are JSON only.
19
+ *
20
+ * Returns the parsed value on success, or an error message on failure. The value is typed `unknown`,
21
+ * so callers narrow it (see `jsonToNetworkDefinition`) or stringify it as needed.
22
+ */
23
+ export declare const parseNetworkFileContent: (text: string) => {
24
+ success: true;
25
+ data: unknown;
26
+ } | {
27
+ success: false;
28
+ error: string;
29
+ };
30
+ /**
31
+ * Converts a parsed network definition into an array of AgentNetworkDefinitionEntry objects
32
+ * suitable for sendNetworkDesignerRequest.
33
+ *
34
+ * Expects the top-level array shape — each entry carrying `origin`, `tools`, `display_as`, etc.
35
+ * Anything that isn't an array yields an empty result, and entries without a string `origin`
36
+ * are dropped. The `instructions` and `description` fields are trimmed when present.
37
+ */
38
+ export declare const jsonToNetworkDefinition: (parsed: unknown) => AgentNetworkDefinitionEntry[];
39
+ export declare const summarizeNetworkDefinition: (networkDef: AgentNetworkDefinitionEntry[]) => NetworkSummary;
40
+ export declare const formatFileSize: (bytes: number) => string;
41
+ /**
42
+ * Validate a selected file against the advertised constraints before reading it.
43
+ *
44
+ * The `<input accept>` attribute only filters the file-picker dialog and is bypassed
45
+ * entirely by drag/drop, so we re-check the extension here. We also enforce the
46
+ * advertised size limit — purely a client-side guard so a pathological file can't be
47
+ * fed into the synchronous parsing/regex pass and freeze the UI; the server imposes
48
+ * no such limit.
49
+ *
50
+ * Returns a status the caller can branch on; rendering a message is left to the caller
51
+ * (see `importFileValidationMessage`).
52
+ */
53
+ export declare const validateImportFile: (file: File) => ImportFileValidation;
54
+ export declare const importFileValidationMessage: (validation: ImportFileValidation, file: File) => string | null;
55
+ /** Convert a filename stem to a display-friendly network name.
56
+ *
57
+ * Strips a trailing UUID that neuro-san appends to exported filenames (e.g.
58
+ * `my_network_683b0dfb_4816_464d_9c83_7e59ce6497d3.json` → `My Network`).
59
+ */
60
+ export declare const filenameToNetworkName: (filename: string) => string;
61
+ /** Pick the first non-colliding name by appending an incrementing index (" 2", " 3", …).
62
+ *
63
+ * Starts at 2 and skips any index already in use, so importing "My Network" alongside an existing
64
+ * "My Network" yields "My Network (2)" — or "My Network (3)" if "My Network (2)" is also taken.
65
+ */
66
+ export declare const nextAvailableNetworkName: (baseName: string, existingNames: readonly string[]) => string;
67
+ export declare const ImportNetworkModal: FC<ImportNetworkModalProps>;
@@ -0,0 +1,585 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ Copyright 2026 Cognizant Technology Solutions Corp, www.cognizant.com.
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+ */
17
+ import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
18
+ import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined";
19
+ import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
20
+ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
21
+ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined";
22
+ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
23
+ import Box from "@mui/material/Box";
24
+ import Button from "@mui/material/Button";
25
+ import CircularProgress from "@mui/material/CircularProgress";
26
+ import Step from "@mui/material/Step";
27
+ import StepLabel from "@mui/material/StepLabel";
28
+ import Stepper from "@mui/material/Stepper";
29
+ import { alpha, styled } from "@mui/material/styles";
30
+ import TextField from "@mui/material/TextField";
31
+ import ToggleButton from "@mui/material/ToggleButton";
32
+ import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
33
+ import Tooltip from "@mui/material/Tooltip";
34
+ import Typography from "@mui/material/Typography";
35
+ import startCase from "lodash-es/startCase";
36
+ import { useEffect, useRef, useState } from "react";
37
+ import { splitFilename } from "../../../utils/File.js";
38
+ import { MUIDialog } from "../../Common/MUIDialog.js";
39
+ import { getFrontman } from "../AgentFlow/GraphStructure.js";
40
+ import { DisplayAs } from "../const.js";
41
+ //#region: Constants
42
+ export const IMPORT_MODAL_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
43
+ const IMPORT_MODAL_ACCEPTED_EXTENSIONS = [".json"];
44
+ const ACCEPTED_MIME_TYPES = IMPORT_MODAL_ACCEPTED_EXTENSIONS.join(", ");
45
+ const STEPS = ["Select file", "Review", "Confirm"];
46
+ /** Trailing UUID on a filename stem. Separator is `[_-]` because filename sanitization
47
+ * (`toSafeFilename`, neuro-san exports) flattens the UUID's hyphens to underscores.
48
+ */
49
+ const FILENAME_TRAILING_UUID_PATTERN = /[_-][0-9a-fA-F]{8}[_-][0-9a-fA-F]{4}[_-][0-9a-fA-F]{4}[_-][0-9a-fA-F]{4}[_-][0-9a-fA-F]{12}$/u;
50
+ //#endregion: Interfaces and Types
51
+ //#region: Helpers
52
+ /**
53
+ * Parse and validate a network definition file. Imports are JSON only.
54
+ *
55
+ * Returns the parsed value on success, or an error message on failure. The value is typed `unknown`,
56
+ * so callers narrow it (see `jsonToNetworkDefinition`) or stringify it as needed.
57
+ */
58
+ export const parseNetworkFileContent = (text) => {
59
+ if (text.trim() === "") {
60
+ return { success: false, error: "The file is empty." };
61
+ }
62
+ try {
63
+ return { success: true, data: JSON.parse(text) };
64
+ }
65
+ catch (err) {
66
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
67
+ }
68
+ };
69
+ /**
70
+ * Converts a parsed network definition into an array of AgentNetworkDefinitionEntry objects
71
+ * suitable for sendNetworkDesignerRequest.
72
+ *
73
+ * Expects the top-level array shape — each entry carrying `origin`, `tools`, `display_as`, etc.
74
+ * Anything that isn't an array yields an empty result, and entries without a string `origin`
75
+ * are dropped. The `instructions` and `description` fields are trimmed when present.
76
+ */
77
+ export const jsonToNetworkDefinition = (parsed) => {
78
+ if (!Array.isArray(parsed))
79
+ return [];
80
+ return parsed
81
+ .filter((entry) => typeof entry?.["origin"] === "string")
82
+ .map((entry) => ({
83
+ ...entry,
84
+ ...(entry.instructions !== undefined && { instructions: entry.instructions.trim() }),
85
+ ...(entry.description !== undefined && { description: entry.description.trim() }),
86
+ }));
87
+ };
88
+ // Summarises a parsed network definition (counts by `display_as`, plus the frontman).
89
+ export const summarizeNetworkDefinition = (networkDef) => {
90
+ const { agents, codedTools, externalAgents } = networkDef.reduce((acc, entry) => {
91
+ switch (entry.display_as) {
92
+ case DisplayAs.LLM_AGENT:
93
+ acc.agents += 1;
94
+ break;
95
+ case DisplayAs.CODED_TOOL:
96
+ acc.codedTools += 1;
97
+ break;
98
+ case DisplayAs.EXTERNAL_AGENT:
99
+ acc.externalAgents += 1;
100
+ break;
101
+ default:
102
+ break;
103
+ }
104
+ return acc;
105
+ }, { agents: 0, codedTools: 0, externalAgents: 0 });
106
+ return {
107
+ agents,
108
+ codedTools,
109
+ externalAgents,
110
+ frontman: getFrontman(networkDef)?.origin ?? networkDef[0]?.origin ?? "—",
111
+ };
112
+ };
113
+ // Format byte count to a human-readable string (e.g. "4.2 KB").
114
+ export const formatFileSize = (bytes) => {
115
+ if (bytes < 1024)
116
+ return `${bytes} B`;
117
+ if (bytes < 1024 * 1024)
118
+ return `${(bytes / 1024).toFixed(1)} KB`;
119
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
120
+ };
121
+ /**
122
+ * Validate a selected file against the advertised constraints before reading it.
123
+ *
124
+ * The `<input accept>` attribute only filters the file-picker dialog and is bypassed
125
+ * entirely by drag/drop, so we re-check the extension here. We also enforce the
126
+ * advertised size limit — purely a client-side guard so a pathological file can't be
127
+ * fed into the synchronous parsing/regex pass and freeze the UI; the server imposes
128
+ * no such limit.
129
+ *
130
+ * Returns a status the caller can branch on; rendering a message is left to the caller
131
+ * (see `importFileValidationMessage`).
132
+ */
133
+ export const validateImportFile = (file) => {
134
+ const { ext } = splitFilename(file.name);
135
+ const normalizedExt = `.${ext.toLowerCase()}`;
136
+ if (!IMPORT_MODAL_ACCEPTED_EXTENSIONS.includes(normalizedExt)) {
137
+ return "unsupported_type";
138
+ }
139
+ if (file.size > IMPORT_MODAL_MAX_FILE_SIZE_BYTES) {
140
+ return "too_large";
141
+ }
142
+ return "valid";
143
+ };
144
+ // Human-readable explanation for a non-VALID validation status, or null if the file is acceptable.
145
+ export const importFileValidationMessage = (validation, file) => {
146
+ switch (validation) {
147
+ case "unsupported_type": {
148
+ const { ext } = splitFilename(file.name);
149
+ const accepted = IMPORT_MODAL_ACCEPTED_EXTENSIONS.join(" and ");
150
+ return `Unsupported file type${ext ? ` ".${ext}"` : ""}. Accepts ${accepted}.`;
151
+ }
152
+ case "too_large": {
153
+ const max = formatFileSize(IMPORT_MODAL_MAX_FILE_SIZE_BYTES);
154
+ return `File is too large (${formatFileSize(file.size)}). Maximum size is ${max}.`;
155
+ }
156
+ case "valid":
157
+ default:
158
+ return null;
159
+ }
160
+ };
161
+ /** Convert a filename stem to a display-friendly network name.
162
+ *
163
+ * Strips a trailing UUID that neuro-san appends to exported filenames (e.g.
164
+ * `my_network_683b0dfb_4816_464d_9c83_7e59ce6497d3.json` → `My Network`).
165
+ */
166
+ export const filenameToNetworkName = (filename) => {
167
+ const { name: stem } = splitFilename(filename);
168
+ return startCase(stem.replace(FILENAME_TRAILING_UUID_PATTERN, ""));
169
+ };
170
+ // Normalize a network name for conflict comparison: underscores, hyphens, parentheses and whitespace
171
+ // all collapse to single spaces, so the display form "My Network (2)" and its API form "My_Network_2"
172
+ // compare equal.
173
+ const normalizeForComparison = (rawName) => {
174
+ const spaced = rawName.replaceAll(/[\s_()-]+/gu, " ").toLowerCase();
175
+ return spaced.trim();
176
+ };
177
+ /** Pick the first non-colliding name by appending an incrementing index (" 2", " 3", …).
178
+ *
179
+ * Starts at 2 and skips any index already in use, so importing "My Network" alongside an existing
180
+ * "My Network" yields "My Network (2)" — or "My Network (3)" if "My Network (2)" is also taken.
181
+ */
182
+ export const nextAvailableNetworkName = (baseName, existingNames) => {
183
+ const taken = new Set(existingNames.map((existing) => normalizeForComparison(existing)));
184
+ let index = 2;
185
+ while (taken.has(normalizeForComparison(`${baseName} (${index})`))) {
186
+ index += 1;
187
+ }
188
+ return `${baseName} (${index})`;
189
+ };
190
+ //#endregion: Helpers
191
+ //#region: Styled Components
192
+ const DropZone = styled(Box, {
193
+ shouldForwardProp: (prop) => prop !== "isDragOver",
194
+ })(({ theme, isDragOver }) => ({
195
+ alignItems: "center",
196
+ borderRadius: theme.shape.borderRadius,
197
+ borderStyle: "dashed",
198
+ borderWidth: "2px",
199
+ borderColor: isDragOver ? theme.palette.primary.main : theme.palette.divider,
200
+ cursor: "pointer",
201
+ display: "flex",
202
+ flexDirection: "column",
203
+ gap: theme.spacing(1),
204
+ justifyContent: "center",
205
+ marginTop: theme.spacing(3),
206
+ minHeight: "220px",
207
+ padding: theme.spacing(4),
208
+ transition: "border-color 0.2s ease",
209
+ }));
210
+ // Hidden file input, triggered programmatically via the DropZone. MUI's idiomatic
211
+ // visually-hidden pattern (https://mui.com/material-ui/react-button/#file-upload).
212
+ const VisuallyHiddenInput = styled("input")({
213
+ bottom: 0,
214
+ clip: "rect(0 0 0 0)",
215
+ clipPath: "inset(50%)",
216
+ height: 1,
217
+ left: 0,
218
+ overflow: "hidden",
219
+ position: "absolute",
220
+ whiteSpace: "nowrap",
221
+ width: 1,
222
+ });
223
+ //#endregion: Styled Components
224
+ const EMPTY_NETWORK_NAMES = [];
225
+ export const ImportNetworkModal = ({ existingNetworkNames = EMPTY_NETWORK_NAMES, isOpen, onClose, onImport, }) => {
226
+ const [activeStep, setActiveStep] = useState(0);
227
+ // When the imported name conflicts, how the user wants to resolve it.
228
+ const [conflictResolution, setConflictResolution] = useState("keep-both");
229
+ const [file, setFile] = useState(null);
230
+ const fileInputRef = useRef(null);
231
+ const [isDragOver, setIsDragOver] = useState(false);
232
+ const [networkName, setNetworkName] = useState("");
233
+ const [parseState, setParseState] = useState(null);
234
+ const [parseError, setParseError] = useState(null);
235
+ const [parsedData, setParsedData] = useState(null);
236
+ // Reset all state whenever the modal is opened
237
+ useEffect(() => {
238
+ if (isOpen) {
239
+ setActiveStep(0);
240
+ setConflictResolution("keep-both");
241
+ setIsDragOver(false);
242
+ setFile(null);
243
+ setNetworkName("");
244
+ setParseState(null);
245
+ setParseError(null);
246
+ setParsedData(null);
247
+ }
248
+ }, [isOpen]);
249
+ // Read and parse the selected file. Driven by an effect so the read is owned by the component
250
+ // lifecycle: the cleanup aborts an in-flight FileReader (discarding its load/error listeners) if
251
+ // the modal closes or a new file is selected before this read finishes, preventing a stale read
252
+ // from updating state out from under a newer one.
253
+ useEffect(() => {
254
+ if (!file)
255
+ return undefined;
256
+ // Validate extension + size before reading. The <input accept> filter only
257
+ // hints the picker and is bypassed by drag/drop, so unsupported or oversized
258
+ // files would otherwise be fed straight into the parser.
259
+ const validation = validateImportFile(file);
260
+ if (validation !== "valid") {
261
+ setParseState("error");
262
+ setParseError(importFileValidationMessage(validation, file));
263
+ return undefined;
264
+ }
265
+ setParseState("loading");
266
+ setParseError(null);
267
+ const reader = new FileReader();
268
+ reader.addEventListener("load", (event) => {
269
+ const text = event.target?.result;
270
+ const result = parseNetworkFileContent(text);
271
+ if ("error" in result) {
272
+ setParseState("error");
273
+ setParseError(`Parse error: ${result.error}`);
274
+ return;
275
+ }
276
+ setParsedData(result.data);
277
+ setParseState("success");
278
+ setNetworkName(filenameToNetworkName(file.name));
279
+ setConflictResolution("keep-both");
280
+ });
281
+ reader.addEventListener("error", () => {
282
+ setParseState("error");
283
+ setParseError("Failed to read the file.");
284
+ });
285
+ // We use FileReader (rather than the blob's text() promise) so the read can be
286
+ // aborted on cleanup and report failures via the "error" event above.
287
+ // eslint-disable-next-line unicorn/prefer-blob-reading-methods
288
+ reader.readAsText(file);
289
+ return () => reader.abort();
290
+ }, [file]);
291
+ // True if `candidate` collides with any existing network name (normalized for comparison).
292
+ const nameConflictsWith = (candidate) => existingNetworkNames.some((existing) => normalizeForComparison(existing) === normalizeForComparison(candidate));
293
+ // The name pulled from the filename — fixed for the selected file. Whether it collides with an
294
+ // existing network is what drives the conflict-resolution UI (it stays visible while the user
295
+ // types a replacement, so we can't key it off the editable name).
296
+ const importedName = file ? filenameToNetworkName(file.name) : "";
297
+ const importedNameHasConflict = nameConflictsWith(importedName);
298
+ const trimmedName = networkName.trim();
299
+ const newNameHasConflict = nameConflictsWith(trimmedName);
300
+ // Advance to the review step and kick off reading/parsing the selected file.
301
+ const processFile = (selectedFile) => {
302
+ setActiveStep(1);
303
+ setParsedData(null);
304
+ setConflictResolution("keep-both");
305
+ // Setting the file kicks off the read effect below, which owns reading/parsing and
306
+ // cleans up its own FileReader.
307
+ setFile(selectedFile);
308
+ };
309
+ const handleDragOver = (event) => {
310
+ event.preventDefault();
311
+ setIsDragOver(true);
312
+ };
313
+ const handleDragLeave = (event) => {
314
+ event.preventDefault();
315
+ setIsDragOver(false);
316
+ };
317
+ const handleDrop = (event) => {
318
+ event.preventDefault();
319
+ setIsDragOver(false);
320
+ const dropped = event.dataTransfer.files[0];
321
+ if (dropped)
322
+ processFile(dropped);
323
+ };
324
+ const handleBrowseClick = () => {
325
+ fileInputRef.current?.click();
326
+ };
327
+ const handleFileChange = (event) => {
328
+ const selected = event.target.files?.[0];
329
+ if (selected)
330
+ processFile(selected);
331
+ // Reset input so the same file can be re-selected if needed
332
+ event.target.value = "";
333
+ };
334
+ const handleBack = () => setActiveStep((prev) => prev - 1);
335
+ const handleContinue = () => {
336
+ setConflictResolution("keep-both");
337
+ // On a collision, "Keep both" defaults to the next free indexed name (e.g. "My Network (2)")
338
+ // so the import is valid out of the box — the user can still edit it.
339
+ if (importedNameHasConflict) {
340
+ setNetworkName(nextAvailableNetworkName(importedName, existingNetworkNames));
341
+ }
342
+ setActiveStep(2);
343
+ };
344
+ const handleImport = () => {
345
+ if (parseState !== "success")
346
+ return;
347
+ // "Replace existing" overwrites the colliding network, so always send the original
348
+ // imported name; "Keep both" sends whatever unique name the user typed.
349
+ const nameToImport = importedNameHasConflict && conflictResolution === "replace" ? importedName : networkName;
350
+ // The API echoes back agent_network_name as-is, and the UI splits on underscores to
351
+ // produce display names — so send underscores instead of spaces. Parentheses around an
352
+ // auto-appended index ("My Network (2)") are dropped so the API name reads "My_Network_2".
353
+ const apiName = nameToImport.trim().replaceAll(" ", "_").replaceAll(/[()]/gu, "");
354
+ onImport?.(apiName, JSON.stringify(parsedData));
355
+ onClose();
356
+ };
357
+ // Switching modes resets the editable name: "Keep both" pre-fills the next free indexed name
358
+ // (e.g. "My Network (2)") so the user doesn't have to invent one, and "Replace existing" targets
359
+ // the original colliding name.
360
+ const handleConflictResolutionChange = (resolution) => {
361
+ setConflictResolution(resolution);
362
+ setNetworkName(resolution === "keep-both" ? nextAvailableNetworkName(importedName, existingNetworkNames) : importedName);
363
+ };
364
+ const { name: fileStem, ext: fileNameExt } = file ? splitFilename(file.name) : { name: "", ext: "" };
365
+ const fileExt = fileNameExt.toUpperCase();
366
+ // Summary shown on the review step. parsedData is the already-validated parsed value, so this never throws.
367
+ const networkSummary = parseState === "success" ? summarizeNetworkDefinition(jsonToNetworkDefinition(parsedData)) : null;
368
+ // The confirm-step primary action adapts to how the conflict is being resolved.
369
+ const isReplacing = importedNameHasConflict && conflictResolution === "replace";
370
+ const importButtonLabel = isReplacing
371
+ ? "Replace network"
372
+ : importedNameHasConflict
373
+ ? "Import as new"
374
+ : "Import network";
375
+ // Replacing overwrites the existing network, so the typed name is irrelevant; otherwise a name
376
+ // is required and must not collide with an existing network.
377
+ const importDisabled = !isReplacing && (!trimmedName || newNameHasConflict);
378
+ // Footer actions, which vary by step (Cancel / Back+Continue / Back+Import).
379
+ const renderFooter = () => {
380
+ switch (activeStep) {
381
+ case 0:
382
+ return (_jsx(Button, { id: "import-network-modal-cancel-btn", onClick: onClose, variant: "outlined", children: "Cancel" }));
383
+ case 1:
384
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { id: "import-network-modal-back-btn", onClick: handleBack, variant: "outlined", children: "Back" }), parseState === "success" && (_jsx(Button, { id: "import-network-modal-continue-btn", onClick: handleContinue, sx: { "&:hover": { backgroundColor: "var(--bs-primary)" } }, variant: "contained", children: "Continue \u2192" }))] }));
385
+ case 2:
386
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { id: "import-network-modal-back-btn", onClick: handleBack, variant: "outlined", children: "Back" }), _jsx(Button, { color: isReplacing ? "error" : "primary", disabled: importDisabled, id: "import-network-modal-import-btn", onClick: handleImport, variant: "contained", children: importButtonLabel })] }));
387
+ default:
388
+ return null;
389
+ }
390
+ };
391
+ // The three-step progress header.
392
+ const renderStepper = () => (_jsx(Stepper, { activeStep: activeStep, id: "import-network-modal-stepper", sx: { marginTop: 1 }, children: STEPS.map((label) => (_jsx(Step, { children: _jsx(StepLabel, { children: label }) }, label))) }));
393
+ // Step 1: the drag-and-drop / browse file picker, plus the restriction callout beneath it.
394
+ const renderSelectFileStep = () => (_jsxs(_Fragment, { children: [_jsxs(DropZone, { isDragOver: isDragOver, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, onClick: handleBrowseClick, onKeyDown: (event) => {
395
+ if (event.key === "Enter" || event.key === " ") {
396
+ event.preventDefault();
397
+ handleBrowseClick();
398
+ }
399
+ }, role: "button", tabIndex: 0, "aria-label": "Drop zone for network definition file", children: [_jsx(Box, { sx: {
400
+ alignItems: "center",
401
+ backgroundColor: (theme) => alpha(theme.palette.primary.main, 0.16),
402
+ borderRadius: 2,
403
+ color: "primary.main",
404
+ display: "flex",
405
+ height: 64,
406
+ justifyContent: "center",
407
+ marginBottom: 1,
408
+ width: 64,
409
+ }, children: _jsx(CloudUploadOutlinedIcon, { id: "import-network-modal-upload-icon", sx: { fontSize: "2rem" } }) }), _jsx(Typography, { id: "import-network-modal-drop-text", variant: "subtitle1", sx: { color: "primary.main", fontWeight: "bold" }, children: "Drag & drop a network definition" }), _jsxs(Typography, { variant: "body2", children: ["or ", _jsx(Box, { component: "button", id: "import-network-modal-browse-link", onClick: (event) => {
410
+ event.stopPropagation();
411
+ handleBrowseClick();
412
+ }, sx: {
413
+ background: "none",
414
+ border: "none",
415
+ color: "var(--bs-secondary)",
416
+ cursor: "pointer",
417
+ font: "inherit",
418
+ padding: 0,
419
+ textDecoration: "underline",
420
+ "&:hover": { color: "var(--bs-primary)", textDecoration: "none" },
421
+ }, children: "browse your files" })] }), _jsxs(Box, { id: "import-network-modal-file-types", sx: {
422
+ alignItems: "center",
423
+ border: "1px solid",
424
+ borderColor: "divider",
425
+ borderRadius: 999,
426
+ color: "text.secondary",
427
+ display: "inline-flex",
428
+ gap: 0.75,
429
+ marginTop: 1,
430
+ padding: "4px 12px",
431
+ }, children: [_jsx(InsertDriveFileOutlinedIcon, { sx: { fontSize: "1rem" } }), _jsx(Typography, { variant: "caption", children: "Accepts .json up to 5 MB" })] }), _jsx(VisuallyHiddenInput, { accept: ACCEPTED_MIME_TYPES, "aria-hidden": "true", "data-testid": "import-network-file-input", onChange: handleFileChange, onClick: (event) => event.stopPropagation(), ref: fileInputRef, tabIndex: -1, type: "file" })] }), _jsxs(Box, { id: "import-network-modal-file-restriction", sx: {
432
+ alignItems: "flex-start",
433
+ backgroundColor: (theme) => alpha(theme.palette.text.primary, 0.04),
434
+ border: "1px solid",
435
+ borderColor: "divider",
436
+ borderRadius: 2,
437
+ display: "flex",
438
+ gap: 1.5,
439
+ marginTop: 2,
440
+ padding: "14px 16px",
441
+ }, children: [_jsx(InfoOutlinedIcon, { sx: { color: "text.secondary", fontSize: "1.25rem", flexShrink: 0, marginTop: 0.25 } }), _jsxs(Typography, { variant: "caption", sx: { color: "text.secondary" }, children: ["Must be a JSON file previously exported from an ", _jsx("strong", { children: "Agent Network Designer" }), "\u2013created network. General Neuro SAN HOCON files are not currently supported."] })] })] }));
442
+ // Step 2: parsing/validation outcome — loading spinner, success summary, or error banner.
443
+ const renderReviewParsing = () => (_jsxs(_Fragment, { children: [_jsx(CircularProgress, { id: "import-network-modal-spinner", size: 48 }), _jsx(Typography, { id: "import-network-modal-parsing-text", variant: "subtitle1", sx: { fontWeight: "bold" }, children: `Parsing & validating ${fileExt}…` }), _jsx(Typography, { id: "import-network-modal-parsing-filename", variant: "body2", sx: { color: "text.secondary", fontFamily: "monospace" }, children: file?.name })] }));
444
+ const renderReviewSuccessBanner = () => (_jsxs(Box, { id: "import-network-modal-success-banner", sx: {
445
+ alignItems: "center",
446
+ backgroundColor: (theme) => alpha(theme.palette.success.main, 0.12),
447
+ border: (theme) => `1px solid ${alpha(theme.palette.success.main, 0.4)}`,
448
+ borderRadius: 2,
449
+ color: "var(--bs-green)",
450
+ display: "flex",
451
+ gap: 1.5,
452
+ padding: "14px 16px",
453
+ }, children: [_jsx(CheckCircleOutlineIcon, {}), _jsxs(Typography, { variant: "body2", children: [`Valid ${fileExt} — `, _jsx(Box, { component: "span", sx: { fontWeight: "bold" }, children: "parsed successfully" })] })] }));
454
+ const renderReviewFileInfo = () => (_jsxs(Box, { sx: {
455
+ alignItems: "center",
456
+ backgroundColor: (theme) => alpha(theme.palette.text.primary, 0.04),
457
+ border: "1px solid",
458
+ borderColor: "divider",
459
+ borderRadius: 2,
460
+ display: "flex",
461
+ gap: 1.5,
462
+ marginTop: 3,
463
+ padding: "12px 16px",
464
+ }, children: [_jsx(Box, { sx: {
465
+ alignItems: "center",
466
+ backgroundColor: (theme) => alpha(theme.palette.text.primary, 0.06),
467
+ borderRadius: 1.5,
468
+ color: "text.secondary",
469
+ display: "flex",
470
+ flexShrink: 0,
471
+ height: 44,
472
+ justifyContent: "center",
473
+ width: 44,
474
+ }, children: _jsx(InsertDriveFileOutlinedIcon, { id: "import-network-modal-file-icon", sx: { fontSize: "1.5rem" } }) }), _jsx(Tooltip, { title: file?.name ?? "", children: _jsxs(Box, { id: "import-network-modal-review-filename", sx: {
475
+ display: "flex",
476
+ flex: 1,
477
+ fontFamily: "monospace",
478
+ fontSize: 15,
479
+ minWidth: 0,
480
+ }, children: [_jsx(Box, { component: "span", sx: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: fileStem }), fileNameExt && (_jsx(Box, { component: "span", sx: { flexShrink: 0 }, children: `.${fileNameExt}` }))] }) }), _jsx(Typography, { id: "import-network-modal-review-filesize", variant: "body2", sx: {
481
+ color: "text.secondary",
482
+ flexShrink: 0,
483
+ fontSize: 15,
484
+ whiteSpace: "nowrap",
485
+ }, children: formatFileSize(file?.size ?? 0) })] }));
486
+ const renderReviewSummary = () => networkSummary && (_jsx(Box, { id: "import-network-modal-summary", sx: {
487
+ border: "1px solid",
488
+ borderColor: "divider",
489
+ borderRadius: 2,
490
+ display: "grid",
491
+ gridTemplateColumns: "1fr 1fr",
492
+ marginTop: 3,
493
+ overflow: "hidden",
494
+ }, children: [
495
+ { label: "Agents", value: networkSummary.agents },
496
+ { label: "Coded tools", value: networkSummary.codedTools },
497
+ { label: "External agents", value: networkSummary.externalAgents },
498
+ { label: "Front man", value: networkSummary.frontman, isFrontman: true },
499
+ ].map((stat, index) => (_jsxs(Box, { sx: {
500
+ borderColor: "divider",
501
+ borderLeft: index % 2 === 1 ? "1px solid" : undefined,
502
+ borderTop: index >= 2 ? "1px solid" : undefined,
503
+ padding: "14px 16px",
504
+ }, children: [_jsx(Typography, { variant: "caption", sx: {
505
+ color: "text.secondary",
506
+ letterSpacing: "0.08em",
507
+ textTransform: "uppercase",
508
+ }, children: stat.label }), _jsx(Typography, { sx: {
509
+ fontFamily: "isFrontman" in stat ? "monospace" : undefined,
510
+ fontSize: "isFrontman" in stat ? 15 : 18,
511
+ lineHeight: 1.4,
512
+ marginTop: 0.5,
513
+ wordBreak: "break-word",
514
+ }, children: stat.value })] }, stat.label))) }));
515
+ const renderReviewSuccess = () => (_jsxs(Box, { sx: { width: "100%" }, children: [renderReviewSuccessBanner(), renderReviewFileInfo(), renderReviewSummary()] }));
516
+ const renderReviewError = () => (_jsxs(Box, { id: "import-network-modal-error-banner", sx: {
517
+ alignItems: "center",
518
+ backgroundColor: "error.dark",
519
+ borderRadius: 1,
520
+ color: "error.contrastText",
521
+ display: "flex",
522
+ gap: 1,
523
+ padding: "10px 14px",
524
+ width: "100%",
525
+ }, children: [_jsx(ErrorOutlineIcon, { fontSize: "small" }), _jsx(Typography, { variant: "body2", children: parseError })] }));
526
+ const renderReviewStep = () => (_jsxs(Box, { id: "import-network-modal-review", sx: {
527
+ alignItems: "center",
528
+ display: "flex",
529
+ flexDirection: "column",
530
+ gap: 2,
531
+ marginTop: 4,
532
+ minHeight: "220px",
533
+ }, children: [parseState === "loading" && renderReviewParsing(), parseState === "success" && renderReviewSuccess(), parseState === "error" && renderReviewError()] }));
534
+ // Step 3: name the network and resolve any collision with an existing one.
535
+ const renderConflictKeepBothField = () => (_jsxs(Box, { children: [_jsx(Typography, { id: "import-network-modal-name-label", variant: "caption", sx: {
536
+ color: "text.secondary",
537
+ fontWeight: "bold",
538
+ letterSpacing: "0.08em",
539
+ textTransform: "uppercase",
540
+ }, children: "New network name" }), _jsx(TextField, { id: "import-network-modal-name-input", fullWidth: true, onChange: (event) => setNetworkName(event.target.value), size: "small", sx: { marginTop: 0.5 }, value: networkName }), newNameHasConflict ? (_jsxs(Box, { id: "import-network-modal-name-taken", sx: { alignItems: "center", display: "flex", gap: 0.5, marginTop: 0.75 }, children: [_jsx(WarningAmberIcon, { fontSize: "small", sx: { color: "warning.main", flexShrink: 0 } }), _jsx(Typography, { variant: "body2", sx: { color: "warning.main", fontWeight: "bold" }, children: "That name is taken. Choose a new name to keep both networks." })] })) : (trimmedName !== "" && (_jsxs(Box, { id: "import-network-modal-name-available", sx: { alignItems: "center", display: "flex", gap: 0.5, marginTop: 0.75 }, children: [_jsx(CheckCircleOutlineIcon, { fontSize: "small", sx: { color: "success.main", flexShrink: 0 } }), _jsx(Typography, { variant: "body2", sx: { color: "success.main", fontWeight: "bold" }, children: "Name is available." })] })))] }));
541
+ const renderConflictReplaceWarning = () => (_jsxs(Box, { id: "import-network-modal-replace-warning", sx: {
542
+ alignItems: "center",
543
+ backgroundColor: (theme) => alpha(theme.palette.error.main, 0.12),
544
+ borderRadius: 1,
545
+ borderStyle: "solid",
546
+ borderWidth: "1px",
547
+ borderColor: "error.main",
548
+ display: "flex",
549
+ gap: 1,
550
+ padding: "10px 14px",
551
+ }, children: [_jsx(WarningAmberIcon, { fontSize: "small", sx: { color: "error.main", flexShrink: 0 } }), _jsxs(Typography, { variant: "body2", children: [_jsxs("strong", { children: ["\"", importedName, "\""] }), " will be ", _jsx("strong", { children: "permanently overwritten" }), ". This can't be undone."] })] }));
552
+ const renderConflictResolution = () => (_jsxs(_Fragment, { children: [_jsxs(Box, { id: "import-network-modal-conflict-prompt", children: [_jsx(Typography, { variant: "caption", sx: {
553
+ color: "text.secondary",
554
+ fontWeight: "bold",
555
+ letterSpacing: "0.08em",
556
+ textTransform: "uppercase",
557
+ }, children: "Name conflict" }), _jsxs(Typography, { variant: "body2", sx: { marginTop: 0.5 }, children: ["A network named ", _jsxs("strong", { children: ["\"", importedName, "\""] }), " already exists. How would you like to handle it?"] })] }), _jsxs(ToggleButtonGroup, { id: "import-network-modal-conflict-toggle", exclusive: true, fullWidth: true, onChange: (_, value) => {
558
+ if (value !== null)
559
+ handleConflictResolutionChange(value);
560
+ }, value: conflictResolution, sx: {
561
+ "& .MuiToggleButton-root": {
562
+ textTransform: "none",
563
+ },
564
+ "& #import-network-modal-keep-both-btn.Mui-selected": {
565
+ backgroundColor: "primary.main",
566
+ color: "primary.contrastText",
567
+ "&:hover": { backgroundColor: "primary.dark" },
568
+ },
569
+ "& #import-network-modal-replace-existing-btn.Mui-selected": {
570
+ backgroundColor: "error.main",
571
+ color: "error.contrastText",
572
+ "&:hover": { backgroundColor: "error.dark" },
573
+ },
574
+ }, children: [_jsx(ToggleButton, { id: "import-network-modal-keep-both-btn", value: "keep-both", children: "Keep both" }), _jsx(ToggleButton, { id: "import-network-modal-replace-existing-btn", value: "replace", children: "Replace existing" })] }), conflictResolution === "keep-both" ? renderConflictKeepBothField() : renderConflictReplaceWarning()] }));
575
+ const renderNameField = () => (_jsxs(Box, { children: [_jsx(Typography, { id: "import-network-modal-name-label", variant: "caption", sx: {
576
+ color: "text.secondary",
577
+ fontWeight: "bold",
578
+ letterSpacing: "0.08em",
579
+ textTransform: "uppercase",
580
+ }, children: "Network name" }), _jsx(TextField, { id: "import-network-modal-name-input", error: newNameHasConflict, fullWidth: true, helperText: newNameHasConflict
581
+ ? "That name is taken. Pick another to continue."
582
+ : "Pulled from the filename — edit if you like.", onChange: (event) => setNetworkName(event.target.value), size: "small", sx: { marginTop: 0.5 }, value: networkName })] }));
583
+ const renderConfirmStep = () => (_jsx(Box, { id: "import-network-modal-confirm", sx: { display: "flex", flexDirection: "column", gap: 2, marginTop: 3 }, children: importedNameHasConflict ? renderConflictResolution() : renderNameField() }));
584
+ return (_jsxs(MUIDialog, { id: "import-network-modal", isOpen: isOpen, onClose: onClose, title: "Import network definition", paperProps: { minWidth: "560px" }, footer: renderFooter(), children: [renderStepper(), activeStep === 0 && renderSelectFileStep(), activeStep === 1 && renderReviewStep(), activeStep === 2 && renderConfirmStep()] }));
585
+ };