This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new cb6e5c6499 feat(python-notebook-migration, frontend): remove the
workspace toolbar entry point (#7571)
cb6e5c6499 is described below
commit cb6e5c649978215188ea92937ec583be9990c49e
Author: Ryan Zhang <[email protected]>
AuthorDate: Tue Aug 11 15:42:13 2026 -0700
feat(python-notebook-migration, frontend): remove the workspace toolbar
entry point (#7571)
### What changes were proposed in this PR?
This is the first PR of moving the AI generate workflow entry point from
the workspace toolbar to the workflow dashboard (#7360). It removes the
workspace toolbar entry point and the UI that exists only to serve it,
so the canvas and dashboard versions never coexist.
**Menu toolbar (`menu.component.{ts,html,scss}`)**
- Removes the "AI generate workflow" button and the flow it started
- Removes the now unused output that signaled the loading overlay, along
with the imports and constructor dependencies that only the removed code
used. The auto layout action and the workflow modifiable state stay,
since other toolbar buttons rely on them.
**Workspace (`workspace.component.{ts,html,scss}`)**
- Removes the loading overlay and its elapsed time stopwatch, which were
driven by the toolbar output.
- Keeps the embedded notebook panel host. It displays a workflow's
stored notebook and is driven by the workflow id, so it keeps working
for the dashboard entry point.
**Retained (shared, reused by the dashboard entry point)**
- The import modal component and its diagram asset and license
attributions.
- The notebook to workflow conversion service.
- The embedded notebook panel and its per workflow initialization.
- The expand Jupyter panel button
After this change the tool has no entry point until the dashboard entry
point lands. The feature stays behind its existing feature flag, so
users see no change.
### Any related issues, documentation, discussions?
Closes #7564
Parent issue #4301
### How was this PR tested?
Updated `menu.component.spec.ts` and `workspace.component.spec.ts` to
drop the tests for the removed button, the generation pipeline, and the
loading timer. No new behavior is added, so no new tests were needed.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
---
.../workspace/component/menu/menu.component.html | 10 -
.../component/menu/menu.component.spec.ts | 406 +--------------------
.../app/workspace/component/menu/menu.component.ts | 245 +------------
.../workspace/component/workspace.component.html | 17 +-
.../workspace/component/workspace.component.scss | 24 --
.../component/workspace.component.spec.ts | 82 -----
.../app/workspace/component/workspace.component.ts | 44 ---
7 files changed, 9 insertions(+), 819 deletions(-)
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html
b/frontend/src/app/workspace/component/menu/menu.component.html
index 8b5314b49a..34fa3dccc4 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -137,16 +137,6 @@
nz-icon
nzType="info-circle"></i>
</button>
- <button
- *ngIf="pythonNotebookMigrationEnabled"
- nz-button
- [disabled]="!isWorkflowModifiable || isWaitingForLLM"
- (click)="openImportNotebookModal()"
- title="AI generate workflow">
- <i
- nz-icon
- nzType="robot"></i>
- </button>
<button
*ngIf="pythonNotebookMigrationEnabled && (jupyterNotebookExists$ |
async)"
nz-button
diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
index f35cea2ac1..9a35071cc2 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
@@ -38,10 +38,7 @@ import { WorkflowActionService } from
"../../service/workflow-graph/model/workfl
import { ValidationWorkflowService, ValidationOutput } from
"../../service/validation/validation-workflow.service";
import { PanelService } from "../../service/panel/panel.service";
import { WorkflowVersionService } from
"../../../dashboard/service/user/workflow-version/workflow-version.service";
-import {
- WorkflowPersistService,
- DEFAULT_WORKFLOW_NAME,
-} from "../../../common/service/workflow-persist/workflow-persist.service";
+import { WorkflowPersistService } from
"../../../common/service/workflow-persist/workflow-persist.service";
import { NotificationService } from
"../../../common/service/notification/notification.service";
import { ExecutionState } from "../../types/execute-workflow.interface";
import { ComputingUnitState } from
"../../../common/type/computing-unit-connection.interface";
@@ -52,13 +49,10 @@ import type { ComputingUnitSelectionComponent } from
"../power-button/computing-
import { WorkflowContent } from "../../../common/type/workflow";
import { Router } from "@angular/router";
import { ReportGenerationService } from
"../../service/report-generation/report-generation.service";
-import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
-import { JupyterPanelService } from
"../../service/jupyter-panel/jupyter-panel.service";
-import { NotebookMigrationService } from
"../../service/notebook-migration/notebook-migration.service";
-import { NotebookImportModalComponent } from
"../notebook-import-modal/notebook-import-modal.component";
-import { NzUploadFile } from "ng-zorro-antd/upload";
+import { USER_WORKFLOW } from "../../../app-routing.constant";
import { GuiConfigService } from "../../../common/service/gui-config.service";
import { MockGuiConfigService } from
"../../../common/service/gui-config.service.mock";
+import { JupyterPanelService } from
"../../service/jupyter-panel/jupyter-panel.service";
import type { Mocked } from "vitest";
vi.mock("file-saver", () => ({ saveAs: vi.fn() }));
@@ -918,398 +912,4 @@ describe("MenuComponent", () => {
expect(openSpy).toHaveBeenCalled();
});
});
-
- // Coverage for the notebook -> workflow import flow: the modal wiring, the
- // upload guard, and the read/generate/persist pipeline. The pipeline tests
- // double as regressions for the spinner bugs (emit true on start, emit false
- // only at the terminal state of every path).
- describe("notebook import", () => {
- let notebookMigrationService: NotebookMigrationService;
- let jupyterPanelService: JupyterPanelService;
-
- const validNotebook = {
- cells: [{ cell_type: "code", source: "print(1)", metadata: {} }],
- metadata: {},
- nbformat: 4,
- nbformat_minor: 5,
- };
-
- // A real File is a Blob, so FileReader.readAsText works in jsdom;
NzUploadFile
- // is the raw File augmented at runtime, matching what nz-upload passes
through.
- function ipynbFile(content: unknown, name = "my_nb.ipynb"): NzUploadFile {
- return new File([JSON.stringify(content)], name, { type:
"application/json" }) as unknown as NzUploadFile;
- }
-
- beforeEach(() => {
- notebookMigrationService = TestBed.inject(NotebookMigrationService);
- jupyterPanelService = TestBed.inject(JupyterPanelService);
- });
-
- it("openImportNotebookModal opens the NotebookImportModalComponent with a
requestImport callback and no menu footer", () => {
- const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as
unknown as NzModalRef);
-
- component.openImportNotebookModal();
-
- expect(createSpy).toHaveBeenCalledTimes(1);
- const config = createSpy.mock.calls[0][0] as ModalOptions;
- expect(config.nzTitle).toBe("AI Generate Workflow from Python Notebook");
- expect(config.nzContent).toBe(NotebookImportModalComponent);
- expect(config.nzFooter).toBeNull();
- expect(typeof (config.nzData as { requestImport: unknown
}).requestImport).toBe("function");
- });
-
- // Opens the modal and returns the requestImport callback the menu handed
to it; calling
- // it drives the overwrite-confirm + import decision (true => close modal,
false => keep open).
- function getRequestImport(): (file: NzUploadFile, model: string) =>
Promise<boolean> {
- const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as
unknown as NzModalRef);
- component.openImportNotebookModal();
- const config = createSpy.mock.calls[0][0] as ModalOptions;
- return (config.nzData as { requestImport: (file: NzUploadFile, model:
string) => Promise<boolean> })
- .requestImport;
- }
-
- it("imports directly and resolves true when the current workflow is
empty", async () => {
- const importSpy = vi.spyOn(component,
"onClickImportNotebook").mockReturnValue(false);
- const confirmSpy = vi.spyOn(modalService,
"confirm").mockImplementation(() => ({}) as NzModalRef);
-
- const proceed = await getRequestImport()({ name: "x.ipynb" } as
NzUploadFile, "gpt-4");
-
- expect(confirmSpy).not.toHaveBeenCalled();
- expect(importSpy).toHaveBeenCalledWith({ name: "x.ipynb" }, "gpt-4");
- expect(proceed).toBe(true);
- });
-
- it("rejects a non-ipynb file: resolves false, errors, and neither confirms
nor imports", async () => {
- const importSpy = vi.spyOn(component,
"onClickImportNotebook").mockReturnValue(false);
- const confirmSpy = vi.spyOn(modalService,
"confirm").mockImplementation(() => ({}) as NzModalRef);
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
-
- const proceed = await getRequestImport()({ name: "data.txt" } as
NzUploadFile, "gpt-4");
-
- // Resolving false keeps the modal open with the selection preserved;
nothing started.
- expect(proceed).toBe(false);
- expect(errorSpy).toHaveBeenCalledWith("Please upload a valid Jupyter
Notebook (.ipynb) file.");
- expect(confirmSpy).not.toHaveBeenCalled();
- expect(importSpy).not.toHaveBeenCalled();
- });
-
- it("confirms before overwriting a non-empty workflow, imports and resolves
true on confirm", async () => {
- workflowActionService.addOperator(mockScanPredicate, mockPoint);
- const importSpy = vi.spyOn(component,
"onClickImportNotebook").mockReturnValue(false);
- const confirmSpy = vi.spyOn(modalService,
"confirm").mockImplementation(() => ({}) as NzModalRef);
-
- const proceedPromise = getRequestImport()({ name: "x.ipynb" } as
NzUploadFile, "gpt-4");
-
- // Confirmation is shown; the import has not started.
- expect(confirmSpy).toHaveBeenCalledTimes(1);
- expect(importSpy).not.toHaveBeenCalled();
-
- // Confirming ("Overwrite") starts the import and lets the modal close.
- const confirmConfig = confirmSpy.mock.calls[0][0] as { nzOnOk: () =>
void };
- confirmConfig.nzOnOk();
- await expect(proceedPromise).resolves.toBe(true);
- expect(importSpy).toHaveBeenCalledWith({ name: "x.ipynb" }, "gpt-4");
- });
-
- it("resolves false without importing when the overwrite confirmation is
cancelled", async () => {
- workflowActionService.addOperator(mockScanPredicate, mockPoint);
- const importSpy = vi.spyOn(component,
"onClickImportNotebook").mockReturnValue(false);
- const confirmSpy = vi.spyOn(modalService,
"confirm").mockImplementation(() => ({}) as NzModalRef);
-
- const proceedPromise = getRequestImport()({ name: "x.ipynb" } as
NzUploadFile, "gpt-4");
-
- // Backing out keeps the modal open (resolve false) and starts no import.
- const confirmConfig = confirmSpy.mock.calls[0][0] as { nzOnCancel: () =>
void };
- confirmConfig.nzOnCancel();
- await expect(proceedPromise).resolves.toBe(false);
- expect(importSpy).not.toHaveBeenCalled();
- });
-
- it("rejects a non-ipynb file without entering the loading state", () => {
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- const result = component.onClickImportNotebook({ name: "data.txt" } as
NzUploadFile, "gpt-4");
-
- expect(result).toBe(false);
- expect(errorSpy).toHaveBeenCalledWith("Please upload a valid Jupyter
Notebook (.ipynb) file.");
- expect(emitSpy).not.toHaveBeenCalledWith(true);
- });
-
- // Import always overwrites the current workflow: it reuses the current
wid so
- // persistWorkflow updates that row in place. When the current workflow
was never
- // saved (no wid) a new row is created and the wid changes, which routes
the notebook
- // send + panel open through JupyterPanelService.init() instead of doing
it here.
- function stubGenerationServices() {
- // 1 == the notebook reached Jupyter; the in-place path opens the panel
only on 1.
- vi.spyOn(notebookMigrationService,
"sendNotebookToJupyter").mockResolvedValue(1 as any);
- vi.spyOn(notebookMigrationService,
"sendToAIGenerateWorkflow").mockResolvedValue({
- workflowContent: { operators: [], links: [], commentBoxes: [],
settings: {} } as unknown as WorkflowContent,
- mappingContent: {} as any,
- });
- vi.spyOn(notebookMigrationService, "setMapping").mockImplementation(()
=> {});
- vi.spyOn(notebookMigrationService,
"storeNotebookAndMapping").mockReturnValue(of({ success: true }) as any);
- vi.spyOn(workflowActionService, "reloadWorkflow").mockImplementation(()
=> {});
- vi.spyOn(jupyterPanelService, "openPanel").mockImplementation(() => {});
- vi.spyOn(notificationService, "success").mockImplementation(() => {});
- // The new-row branch updates the URL via Location.go; stub it out.
- vi.spyOn(location, "go").mockImplementation(() => {});
- }
-
- it("overwrites the saved current workflow in place, reloads it, and opens
the panel itself", async () => {
- stubGenerationServices();
- // Saved current workflow (wid 7); persist keeps the same wid, so the
wid does not change.
- vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7
} as any);
- const persistSpy = vi.spyOn(workflowPersistService,
"persistWorkflow").mockReturnValue(of({ wid: 7 } as any));
- const autoLayoutSpy = vi.spyOn(component,
"onClickAutoLayout").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- expect(emitSpy).toHaveBeenCalledWith(true);
- // Reuses the current wid so the row is overwritten in place; reloads
synchronously on the
- // live canvas and tidies the layout.
- expect(persistSpy.mock.calls[0][0].wid).toBe(7);
- expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith({ wid:
7 }, false);
- expect(autoLayoutSpy).toHaveBeenCalled();
- // wid unchanged: we send the notebook + open the panel ourselves
(init() does not react).
-
expect(notebookMigrationService.sendNotebookToJupyter).toHaveBeenCalled();
-
expect(jupyterPanelService.openPanel).toHaveBeenCalledWith("JupyterNotebookPanel");
- // Stayed on the same workflow, so the URL is not changed.
- expect(location.go).not.toHaveBeenCalled();
- });
-
- it("marks isWaitingForLLM true at the start of import and false once the
flow settles", async () => {
- stubGenerationServices();
- vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7
} as any);
- vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({
wid: 7 } as any));
- vi.spyOn(component, "onClickAutoLayout").mockImplementation(() => {});
-
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- // emit(true) fires synchronously at the start of the import.
- expect(component.isWaitingForLLM).toBe(true);
-
- await vi.waitFor(() => expect(component.isWaitingForLLM).toBe(false));
- });
-
- it("disables the AI-generate button while a conversion is in flight", ()
=> {
- const button = () =>
- fixture.nativeElement.querySelector('button[title="AI generate
workflow"]') as HTMLButtonElement;
- (TestBed.inject(GuiConfigService) as unknown as
MockGuiConfigService).setConfig({
- pythonNotebookMigrationEnabled: true,
- });
- // Isolate the waiting flag's effect from the modifiable gate.
- component.isWorkflowModifiable = true;
- fixture.detectChanges();
- expect(button().disabled).toBe(false);
-
- component.isWaitingForLLM = true;
- fixture.detectChanges();
- expect(button().disabled).toBe(true);
- });
-
- it("clicking the AI-generate button opens the import modal", () => {
- const openSpy = vi.spyOn(component,
"openImportNotebookModal").mockImplementation(() => {});
- (TestBed.inject(GuiConfigService) as unknown as
MockGuiConfigService).setConfig({
- pythonNotebookMigrationEnabled: true,
- });
- component.isWorkflowModifiable = true; // enable the button so the click
lands
- fixture.detectChanges();
-
- const button = fixture.nativeElement.querySelector('button[title="AI
generate workflow"]') as HTMLButtonElement;
- button.click();
-
- expect(openSpy).toHaveBeenCalled();
- });
-
- it("does not open the panel when the notebook fails to reach Jupyter",
async () => {
- stubGenerationServices();
- // sendNotebookToJupyter resolves 0 on failure (it toasts the error
itself).
- vi.spyOn(notebookMigrationService,
"sendNotebookToJupyter").mockResolvedValue(0 as any);
- vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7
} as any);
- vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({
wid: 7 } as any));
- vi.spyOn(component, "onClickAutoLayout").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
- // Let the sendNotebookToJupyter().then(...) microtask settle before
asserting.
- await Promise.resolve();
-
- // The reload still happened, but the panel stays closed since the send
failed.
-
expect(notebookMigrationService.sendNotebookToJupyter).toHaveBeenCalled();
- expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith({ wid:
7 }, false);
- expect(jupyterPanelService.openPanel).not.toHaveBeenCalled();
- });
-
- it("creates a new row and points the URL at it when the current workflow
was never saved", async () => {
- stubGenerationServices();
- // Current workflow has no wid; persist returns a new wid, so the wid
changes.
- vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid:
undefined } as any);
- const persistSpy = vi.spyOn(workflowPersistService,
"persistWorkflow").mockReturnValue(of({ wid: 99 } as any));
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- // No current wid -> the backend inserts a new row.
- expect(persistSpy.mock.calls[0][0].wid).toBeUndefined();
- expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith({ wid:
99 }, false);
- expect(location.go).toHaveBeenCalledWith(`${USER_WORKSPACE}/99`);
- // wid changed: JupyterPanelService.init() sends the notebook + opens
the panel, not us,
- // so the "sent to Jupyter" toast fires only once.
-
expect(notebookMigrationService.sendNotebookToJupyter).not.toHaveBeenCalled();
- expect(jupyterPanelService.openPanel).not.toHaveBeenCalled();
- });
-
- it("on LLM error: surfaces an error notification and clears the loading
flag", async () => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- vi.spyOn(notebookMigrationService,
"sendNotebookToJupyter").mockResolvedValue(undefined as any);
- vi.spyOn(notebookMigrationService,
"sendToAIGenerateWorkflow").mockRejectedValue(new Error("boom"));
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
-
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- expect(emitSpy).toHaveBeenCalledWith(true);
- expect(errorSpy).toHaveBeenCalledWith("Error while communicating with
LLM, check console for details");
- });
-
- it("on invalid notebook structure: surfaces an error, clears the loading
flag, and never calls jupyter", async () => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- const jupyterSpy = vi.spyOn(notebookMigrationService,
"sendNotebookToJupyter");
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- // No `cells` array -> the structure guard throws before any network
call.
- component.onClickImportNotebook(ipynbFile({ metadata: {} }), "gpt-4");
-
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- expect(errorSpy).toHaveBeenCalledWith("Failed to import the notebook.");
- expect(jupyterSpy).not.toHaveBeenCalled();
- });
-
- it("falls back to the default workflow name when the file has no base
name", async () => {
- stubGenerationServices();
- vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7
} as any);
- const persistSpy = vi.spyOn(workflowPersistService,
"persistWorkflow").mockReturnValue(of({ wid: 7 } as any));
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- // A file named ".ipynb" has an empty base name, so the default name is
used.
- component.onClickImportNotebook(ipynbFile(validNotebook, ".ipynb"),
"gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
-
expect(persistSpy.mock.calls[0][0].name).toBe(`${DEFAULT_WORKFLOW_NAME}_GENERATED_BY_LLM`);
- });
-
- it("uses the whole file name when it has no dot", async () => {
- stubGenerationServices();
- vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7
} as any);
- const persistSpy = vi.spyOn(workflowPersistService,
"persistWorkflow").mockReturnValue(of({ wid: 7 } as any));
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- // A file named "ipynb" (no dot) passes the extension check and has no
extension to strip,
- // so the whole name becomes the base name.
- component.onClickImportNotebook(ipynbFile(validNotebook, "ipynb"),
"gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- expect(persistSpy.mock.calls[0][0].name).toBe("ipynb_GENERATED_BY_LLM");
- });
-
- it("tags code cells that arrive without a metadata object", async () => {
- stubGenerationServices();
- const persistSpy = vi.spyOn(workflowPersistService,
"persistWorkflow").mockReturnValue(of({ wid: 5 } as any));
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- const notebookWithoutCellMetadata = {
- cells: [{ cell_type: "code", source: "x = 1" }],
- metadata: {},
- nbformat: 4,
- };
- component.onClickImportNotebook(ipynbFile(notebookWithoutCellMetadata),
"gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- expect(persistSpy).toHaveBeenCalledTimes(1);
- });
-
- it("on persist failure: surfaces an error notification and clears the
loading flag", async () => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- vi.spyOn(notebookMigrationService,
"sendToAIGenerateWorkflow").mockResolvedValue({
- workflowContent: { operators: [], links: [], commentBoxes: [],
settings: {} } as unknown as WorkflowContent,
- mappingContent: {} as any,
- });
- vi.spyOn(workflowPersistService,
"persistWorkflow").mockReturnValue(throwError(() => new Error("db down")));
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- expect(errorSpy).toHaveBeenCalledWith("Failed to import notebook, check
console for detailed error");
- });
-
- it("on file read error: surfaces an error and clears the loading flag",
async () => {
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
- // Swap in a FileReader that errors instead of loading, so
reader.onerror runs.
- const RealFileReader = globalThis.FileReader;
- class FakeFileReader {
- onerror: ((e: unknown) => void) | null = null;
- onload: (() => void) | null = null;
- readAsText(): void {
- setTimeout(() => this.onerror?.(new Error("read fail")), 0);
- }
- }
- (globalThis as any).FileReader = FakeFileReader;
- try {
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledWith("Failed
to read the notebook file."));
- expect(emitSpy).toHaveBeenCalledWith(false);
- } finally {
- (globalThis as any).FileReader = RealFileReader;
- }
- });
-
- it("on non-string file content: surfaces an error and clears the loading
flag", async () => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
- // Swap in a FileReader that loads a non-string result, so the string
guard throws.
- const RealFileReader = globalThis.FileReader;
- class FakeFileReader {
- result: unknown = null;
- onerror: (() => void) | null = null;
- onload: (() => void) | null = null;
- readAsText(): void {
- setTimeout(() => this.onload?.(), 0);
- }
- }
- (globalThis as any).FileReader = FakeFileReader;
- try {
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
- expect(errorSpy).toHaveBeenCalledWith("Failed to import the
notebook.");
- } finally {
- (globalThis as any).FileReader = RealFileReader;
- }
- });
-
- it("when the LLM returns no result: surfaces an error, clears the loading
flag, and never persists", async () => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- vi.spyOn(notebookMigrationService,
"sendToAIGenerateWorkflow").mockResolvedValue(undefined as any);
- const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow");
- const errorSpy = vi.spyOn(notificationService,
"error").mockImplementation(() => {});
- const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit");
-
- component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4");
- await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false));
-
- expect(errorSpy).toHaveBeenCalledWith("No workflow was generated from
the notebook.");
- expect(persistSpy).not.toHaveBeenCalled();
- });
- });
});
diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts
b/frontend/src/app/workspace/component/menu/menu.component.ts
index 8930d9d0bb..b3e44057da 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.ts
@@ -18,13 +18,10 @@
*/
import { DatePipe, Location, NgIf, NgFor, NgTemplateOutlet, AsyncPipe } from
"@angular/common";
-import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, Output,
EventEmitter } from "@angular/core";
+import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from
"@angular/core";
import { Router, RouterLink } from "@angular/router";
import { UserService } from "../../../common/service/user/user.service";
-import {
- DEFAULT_WORKFLOW_NAME,
- WorkflowPersistService,
-} from "../../../common/service/workflow-persist/workflow-persist.service";
+import { WorkflowPersistService } from
"../../../common/service/workflow-persist/workflow-persist.service";
import { Workflow, WorkflowContent } from "../../../common/type/workflow";
import { ExecuteWorkflowService } from
"../../service/execute-workflow/execute-workflow.service";
import { UndoRedoService } from "../../service/undo-redo/undo-redo.service";
@@ -42,14 +39,14 @@ import { saveAs } from "file-saver";
import { NotificationService } from
"src/app/common/service/notification/notification.service";
import { OperatorMenuService } from
"../../service/operator-menu/operator-menu.service";
import { CoeditorPresenceService } from
"../../service/workflow-graph/model/coeditor-presence.service";
-import { EMPTY, firstValueFrom, of, timer, map } from "rxjs";
+import { EMPTY, firstValueFrom, of, timer } from "rxjs";
import { isDefined } from "../../../common/util/predicate";
import { NzModalService } from "ng-zorro-antd/modal";
import { ResultExportationComponent } from
"../result-exportation/result-exportation.component";
import { ReportGenerationService } from
"../../service/report-generation/report-generation.service";
import { ShareAccessComponent } from
"src/app/dashboard/component/user/share-access/share-access.component";
import { PanelService } from "../../service/panel/panel.service";
-import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
+import { USER_WORKFLOW } from "../../../app-routing.constant";
import { ComputingUnitStatusService } from
"../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { ComputingUnitState } from
"../../../common/type/computing-unit-connection.interface";
import { ComputingUnitSelectionComponent } from
"../power-button/computing-unit-selection.component";
@@ -74,14 +71,6 @@ import { NzSwitchComponent } from "ng-zorro-antd/switch";
import { NzBadgeComponent } from "ng-zorro-antd/badge";
import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
import { JupyterPanelService } from
"../../service/jupyter-panel/jupyter-panel.service";
-import { v4 as uuidv4 } from "uuid";
-import { Notebook } from "../../service/notebook-migration/migration-llm";
-import { NotebookMigrationService } from
"../../service/notebook-migration/notebook-migration.service";
-import {
- NotebookImportModalComponent,
- NotebookImportModalData,
-} from "../notebook-import-modal/notebook-import-modal.component";
-import { NzUploadFile } from "ng-zorro-antd/upload";
/**
* MenuComponent is the top level menu bar that shows
@@ -155,9 +144,6 @@ export class MenuComponent implements OnInit, OnDestroy {
@Input() public currentExecutionName: string = ""; // reset executionName
@Input() public particularVersionDate: string = ""; // placeholder for the
metadata information of a particular workflow version
@ViewChild("workflowNameInput") workflowNameInput:
ElementRef<HTMLInputElement> | undefined;
- // Emit an event to parent component (workspace) when AI generation starts
or stops
- @Output() public setWaitingForLLM = new EventEmitter<boolean>();
- public isWaitingForLLM = false;
// variable bound with HTML to decide if the running spinner should show
public runButtonText = "Run";
@@ -199,8 +185,7 @@ export class MenuComponent implements OnInit, OnDestroy {
private computingUnitStatusService: ComputingUnitStatusService,
protected config: GuiConfigService,
private router: Router,
- private jupyterPanelService: JupyterPanelService,
- private notebookMigrationService: NotebookMigrationService
+ private jupyterPanelService: JupyterPanelService
) {
workflowWebsocketService
.subscribeToEvent("ExecutionDurationUpdateEvent")
@@ -614,226 +599,6 @@ export class MenuComponent implements OnInit, OnDestroy {
this.jupyterPanelService.openJupyterNotebookPanel();
}
- public openImportNotebookModal(): void {
- // The modal owns the upload form and the model dropdown. It delegates the
decision to
- // proceed back here via requestImport so we keep the overwrite-confirm
and the
- // generation pipeline (and the workflow/persist/jupyter state they touch)
in the menu.
- this.modalService.create<NotebookImportModalComponent,
NotebookImportModalData>({
- nzTitle: "AI Generate Workflow from Python Notebook",
- nzContent: NotebookImportModalComponent,
- nzWidth: 700,
- nzFooter: null,
- // Center in the viewport so the overwrite confirm (also centered)
overlays this modal's center.
- nzCentered: true,
- nzData: {
- requestImport: (file, model) => this.confirmAndImport(file, model),
- },
- });
- }
-
- // Decides whether an import may proceed, then kicks it off. Resolves true
when the import
- // has started (the modal should close), false when the user backs out of
the overwrite
- // confirmation (the modal should stay open with the selection intact).
- private confirmAndImport(file: NzUploadFile, model: string):
Promise<boolean> {
- // Reject a non-notebook file here, before starting anything, so the modal
stays open
- // with the selection intact (resolving false) instead of closing on a
no-op import.
- const fileExtension = file.name.split(".").pop()?.toLowerCase();
- if (fileExtension !== "ipynb") {
- this.notificationService.error("Please upload a valid Jupyter Notebook
(.ipynb) file.");
- return Promise.resolve(false);
- }
- const startImport = () => this.onClickImportNotebook(file, model);
- // Generating overwrites the currently open workflow. Confirm first only
when there is
- // actual content to replace; a fresh empty workflow needs no prompt.
- const graph = this.workflowActionService.getTexeraGraph();
- const currentWorkflowHasContent = graph.getAllOperators().length > 0 ||
graph.getAllCommentBoxes().length > 0;
- if (!currentWorkflowHasContent) {
- startImport();
- return Promise.resolve(true);
- }
- return new Promise<boolean>(resolve => {
- this.modalService.confirm({
- nzTitle: "Overwrite current workflow?",
- nzContent:
- "Generating will replace the contents of the workflow you have open.
" +
- "The previous version is kept in this workflow's version history.",
- nzOkText: "Overwrite",
- nzOkDanger: true,
- // Center over the import modal, and leave only Cancel/Overwrite (no
X, no click-outside).
- nzCentered: true,
- nzClosable: false,
- nzMaskClosable: false,
- nzOnOk: () => {
- startImport();
- resolve(true);
- },
- nzOnCancel: () => resolve(false),
- });
- });
- }
-
- public onClickImportNotebook = (file: NzUploadFile, model: string): boolean
=> {
- const reader = new FileReader();
-
- // Check if the file is a Jupyter notebook based on its extension
- const fileExtension = file.name.split(".").pop()?.toLowerCase();
- if (fileExtension !== "ipynb") {
- this.notificationService.error("Please upload a valid Jupyter Notebook
(.ipynb) file.");
- return false;
- }
-
- this.emitWaitingForLLM(true); // start loading
-
- // Read the notebook file as text
- reader.readAsText(file as any);
- reader.onload = async () => {
- try {
- const result = reader.result;
- if (typeof result !== "string") {
- throw new Error("File content is not a valid string.");
- }
-
- // Parse the content of the .ipynb file (it's in JSON format)
- const notebookContent = JSON.parse(result) as Notebook;
-
- // Validate the notebook structure
- if (!notebookContent || !Array.isArray(notebookContent.cells)) {
- throw new Error("Invalid notebook structure.");
- }
-
- // Add UUID's to each cell in the notebook
- for (const cell of notebookContent.cells) {
- if (!cell.metadata) {
- cell.metadata = {};
- }
- cell.metadata.uuid = uuidv4();
- }
-
- // Get workflow and mapping from LLM
- await this.notebookMigrationService
- .sendToAIGenerateWorkflow(notebookContent, model)
- .then(result => {
- if (result) {
- const { workflowContent, mappingContent } = result;
-
- const fileExtensionIndex = file.name.lastIndexOf(".");
- let workflowName: string;
- if (fileExtensionIndex === -1) {
- workflowName = file.name;
- } else {
- workflowName = file.name.substring(0, fileExtensionIndex);
- }
- if (workflowName.trim() === "") {
- workflowName = DEFAULT_WORKFLOW_NAME;
- }
-
- // Always overwrite the current workflow: reuse its wid so
persistWorkflow
- // updates that row in place instead of inserting a new one
(which would leave
- // a duplicate behind). Read it now, after generation, so a wid
assigned by
- // auto-persist during the wait is picked up. If the current
workflow was never
- // saved, wid is undefined and a new row is created (there is
nothing to overwrite).
- const reuseWid = this.workflowActionService.getWorkflow().wid;
-
- const workflow: Workflow = {
- content: workflowContent,
- name: `${workflowName}_GENERATED_BY_LLM`,
- isPublished: 0,
- description: undefined,
- wid: reuseWid,
- creationTime: undefined,
- lastModifiedTime: undefined,
- readonly: false,
- };
-
- this.workflowPersistService
- .persistWorkflow(workflow)
- .pipe(
- switchMap((updatedWorkflow: Workflow) => {
- const mappingID = "mapping_wid_" + updatedWorkflow.wid;
-
- this.notebookMigrationService.setMapping(mappingID,
mappingContent);
-
- return this.notebookMigrationService
- .storeNotebookAndMapping(updatedWorkflow.wid, 1,
mappingContent, notebookContent)
- .pipe(map(() => updatedWorkflow));
- }),
- untilDestroyed(this)
- )
- .subscribe({
- next: updatedWorkflow => {
- this.notificationService.success("Successfully generated
workflow and mapping from notebook.");
- // Reload the generated workflow onto the current (already
live) canvas so it
- // renders immediately; we never remount the workspace.
Render synchronously
- // (asyncRendering = false) so the operators exist before
auto-layout runs.
- this.workflowActionService.reloadWorkflow(updatedWorkflow,
false);
- // Tidy the LLM-generated layout; the position changes get
auto-persisted.
- this.onClickAutoLayout();
- if (reuseWid === updatedWorkflow.wid) {
- // Overwrote the current workflow in place: the wid did
not change, so
- // JupyterPanelService.init() does not react. Send the
notebook to Jupyter
- // and open the panel ourselves. Use openPanel, not
openJupyterNotebookPanel:
- // init()'s wid-change handler is not involved and
openPanel opens
- // unconditionally without the hasMapping gate.
- // sendNotebookToJupyter never rejects: it resolves 1 on
success and 0 on
- // failure (it toasts the error itself). Open the panel
only on success so we
- // do not float it over a blank iframe, matching the
init()-driven path which
- // opens only when fetchNotebookAndMapping reports the
send succeeded.
-
this.notebookMigrationService.sendNotebookToJupyter(notebookContent).then(result
=> {
- if (result == 1) {
-
this.jupyterPanelService.openPanel("JupyterNotebookPanel");
- }
- });
- } else {
- // The current workflow had never been saved, so a new
row was created and the
- // wid changed. reloadWorkflow's synchronous wid change
drives init() to fetch
- // the stored notebook/mapping, send it to Jupyter, and
open the panel, so we
- // do not do that here (doing so would double the "sent
to Jupyter" toast).
- // Point the URL at the generated workflow.
-
this.location.go(`${USER_WORKSPACE}/${updatedWorkflow.wid}`);
- }
- },
- error: (err: unknown) => {
- this.notificationService.error("Failed to import notebook,
check console for detailed error");
- console.error("Import notebook failed:", err);
- this.emitWaitingForLLM(false);
- },
- complete: () => {
- this.emitWaitingForLLM(false);
- },
- });
- } else {
- this.notificationService.error("No workflow was generated from
the notebook.");
- console.error("Result is undefined");
- this.emitWaitingForLLM(false);
- }
- })
- .catch(error => {
- this.notificationService.error("Error while communicating with
LLM, check console for details");
- console.error("Error while fetching data from LLM: ", error);
- this.emitWaitingForLLM(false);
- });
- } catch (error) {
- this.notificationService.error("Failed to import the notebook.");
- console.error(error);
- this.emitWaitingForLLM(false);
- }
- };
-
- reader.onerror = () => {
- this.notificationService.error("Failed to read the notebook file.");
- this.emitWaitingForLLM(false);
- };
-
- return false; // Prevent automatic upload handling
- };
-
- // Keeps the local waiting flag and the parent-facing output in lockstep so
the
- // AI-generate button can be disabled while a conversion is in flight.
- private emitWaitingForLLM(waiting: boolean): void {
- this.isWaitingForLLM = waiting;
- this.setWaitingForLLM.emit(waiting);
- }
-
public onClickExportWorkflow(): void {
const workflowContent: WorkflowContent =
this.workflowActionService.getWorkflowContent();
const workflowContentJson = JSON.stringify(workflowContent, null, 2);
diff --git a/frontend/src/app/workspace/component/workspace.component.html
b/frontend/src/app/workspace/component/workspace.component.html
index 78155285d4..b0c300b728 100644
--- a/frontend/src/app/workspace/component/workspace.component.html
+++ b/frontend/src/app/workspace/component/workspace.component.html
@@ -24,28 +24,13 @@
nzTip="Loading workflow...">
</nz-spin>
</div>
-<div class="openai-spinner">
- <nz-spin
- [nzSize]="'large'"
- [nzSpinning]="isWaitingForLLM"></nz-spin>
-
- @if (isWaitingForLLM) {
- <div class="llm-spinner-text">
- <div>Waiting for LLM response...</div>
- <div>Estimated time 1-5 minutes</div>
- <div class="elapsed-time">Do not close this tab</div>
- <div class="elapsed-time">Elapsed time: {{ formattedElapsedTime }}</div>
- </div>
- }
-</div>
<div id="result">
<texera-result-panel></texera-result-panel>
</div>
<texera-workflow-editor></texera-workflow-editor>
<texera-menu
[writeAccess]="writeAccess"
- [pid]="pid"
- (setWaitingForLLM)="onWaitingForLLMChanged($event)">
+ [pid]="pid">
</texera-menu>
<texera-mini-map class="box"></texera-mini-map>
<texera-left-panel> </texera-left-panel>
diff --git a/frontend/src/app/workspace/component/workspace.component.scss
b/frontend/src/app/workspace/component/workspace.component.scss
index 012fa3dfea..e01b1dc0f2 100644
--- a/frontend/src/app/workspace/component/workspace.component.scss
+++ b/frontend/src/app/workspace/component/workspace.component.scss
@@ -63,27 +63,3 @@ texera-workflow-editor {
:host {
user-select: none;
}
-
-.openai-spinner {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- z-index: 10;
- pointer-events: none;
-}
-
-.llm-spinner-text {
- margin-top: 20px;
- text-align: center;
-}
-
-.elapsed-time {
- color: #ff4d4f;
- font-weight: 500;
-}
diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts
b/frontend/src/app/workspace/component/workspace.component.spec.ts
index d930af601f..f85294e42a 100644
--- a/frontend/src/app/workspace/component/workspace.component.spec.ts
+++ b/frontend/src/app/workspace/component/workspace.component.spec.ts
@@ -479,86 +479,4 @@ describe("WorkspaceComponent", () => {
expect(typeof codeEditorService.vc.createEmbeddedView).toBe("function");
});
});
-
- // The LLM waiting spinner is driven by an elapsed-time timer started/stopped
- // from the menu's setWaitingForLLM output. These tests pin the 1s cadence,
the
- // single-digit minute format, the stop-on-idle behavior, and, crucially,
that
- // the interval is cleared on destroy so it cannot keep firing detectChanges
on
- // a torn-down view.
- describe("LLM waiting timer", () => {
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it("formattedElapsedTime is 0:00 before the timer starts", async () => {
- await createFixture();
- fixture.detectChanges();
- expect(component.formattedElapsedTime).toBe("0:00");
- });
-
- it("onWaitingForLLMChanged(true) starts the timer and advances elapsed
time each second", async () => {
- vi.useFakeTimers();
- await createFixture();
- fixture.detectChanges();
-
- component.onWaitingForLLMChanged(true);
- expect(component.isWaitingForLLM).toBe(true);
- expect(component.formattedElapsedTime).toBe("0:00");
-
- vi.advanceTimersByTime(1000);
- expect(component.formattedElapsedTime).toBe("0:01");
-
- // 1 minute 2 seconds later; minutes are not zero-padded.
- vi.advanceTimersByTime(61000);
- expect(component.formattedElapsedTime).toBe("1:02");
- });
-
- it("onWaitingForLLMChanged(false) stops the timer so elapsed time no
longer advances", async () => {
- vi.useFakeTimers();
- await createFixture();
- fixture.detectChanges();
-
- component.onWaitingForLLMChanged(true);
- vi.advanceTimersByTime(1000);
- expect(component.formattedElapsedTime).toBe("0:01");
-
- component.onWaitingForLLMChanged(false);
- expect(component.isWaitingForLLM).toBe(false);
- // startTime is reset and the interval cleared, so further ticks do
nothing.
- vi.advanceTimersByTime(5000);
- expect(component.formattedElapsedTime).toBe("0:00");
- });
-
- it("clears the interval on destroy so the timer stops firing after
teardown", async () => {
- vi.useFakeTimers();
- await createFixture();
- fixture.detectChanges();
-
- component.onWaitingForLLMChanged(true);
- vi.advanceTimersByTime(1000);
-
- const clearSpy = vi.spyOn(globalThis, "clearInterval");
- component.ngOnDestroy();
-
- expect(clearSpy).toHaveBeenCalled();
- });
-
- it("clears the previous interval when the timer is started again without
stopping", async () => {
- vi.useFakeTimers();
- await createFixture();
- fixture.detectChanges();
-
- const clearSpy = vi.spyOn(globalThis, "clearInterval");
-
- component.onWaitingForLLMChanged(true);
- const firstInterval = (component as any).timerInterval;
-
- // A second start (e.g. a double click) must not leave the first
interval running.
- component.onWaitingForLLMChanged(true);
- const secondInterval = (component as any).timerInterval;
-
- expect(secondInterval).not.toBe(firstInterval);
- expect(clearSpy).toHaveBeenCalledWith(firstInterval);
- });
- });
});
diff --git a/frontend/src/app/workspace/component/workspace.component.ts
b/frontend/src/app/workspace/component/workspace.component.ts
index bdc0cdb8a9..2f95ccccfa 100644
--- a/frontend/src/app/workspace/component/workspace.component.ts
+++ b/frontend/src/app/workspace/component/workspace.component.ts
@@ -93,10 +93,6 @@ export class WorkspaceComponent implements AfterViewInit,
OnInit, OnDestroy {
public pid?: number = undefined;
public writeAccess: boolean = false;
public isLoading: boolean = false;
- // variable to track whether we are waiting for AI to finish generating
(whether a loading icon should show)
- public isWaitingForLLM = false;
- private timerInterval: ReturnType<typeof setInterval> | null = null;
- private startTime: number | null = null;
@ViewChild("codeEditor", { read: ViewContainerRef }) codeEditorViewRef!:
ViewContainerRef;
/**
@@ -204,7 +200,6 @@ export class WorkspaceComponent implements AfterViewInit,
OnInit, OnDestroy {
// re-entered workflow starts clean instead of reusing the previous one.
this.computingUnitStatusService.disconnect();
this.resetWorkflowSessionState();
- this.stopTimer();
}
/**
@@ -362,43 +357,4 @@ export class WorkspaceComponent implements AfterViewInit,
OnInit, OnDestroy {
public get copilotEnabled(): boolean {
return this.config.env.copilotEnabled;
}
-
- onWaitingForLLMChanged(isWaiting: boolean) {
- this.isWaitingForLLM = isWaiting;
-
- if (isWaiting) {
- this.startTimer();
- } else {
- this.stopTimer();
- }
- }
-
- startTimer() {
- this.stopTimer(); // clear any interval already running so repeated starts
don't stack
- this.startTime = Date.now();
- this.updateElapsedTime();
- this.timerInterval = setInterval(() => {
- this.updateElapsedTime();
- }, 1000);
- }
-
- stopTimer() {
- if (this.timerInterval !== null) {
- clearInterval(this.timerInterval);
- }
- this.timerInterval = null;
- this.startTime = null;
- }
-
- updateElapsedTime() {
- this.changeDetectorRef.detectChanges();
- }
-
- get formattedElapsedTime(): string {
- if (!this.startTime) return "0:00";
- const diff = Date.now() - this.startTime;
- const minutes = Math.floor(diff / 60000);
- const seconds = Math.floor((diff % 60000) / 1000);
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
- }
}