This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-5273-1b61a0e46b087172d56bbf9a01eaa0cb91845eb6
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 8b9d017cdb26e7153d2784602fac971fc6470601
Author: Ryan Zhang <[email protected]>
AuthorDate: Thu Aug 6 15:52:36 2026 -0700

    feat(python-notebook-migration, frontend): add AI-generate-workflow modal, 
panel host, and LLM loading spinner (#5273)
    
    ### What changes were proposed in this PR?
    Adds the user facing entry point for the Python notebook to Texera
    workflow migration tool: an "AI generate workflow" button in the
    workspace menu that opens the import modal (added in #7314), a workspace
    loading overlay with an elapsed time stopwatch shown while the LLM
    converts, auto layout of the generated workflow, and the
    `<texera-jupyter-notebook-panel>` host that embeds the iframe panel
    component from #5271.
    
    **Menu toolbar (`menu.component.{ts,html,scss}`)**
    - Two buttons, both gated on the `pythonNotebookMigrationEnabled` config
    flag:
    - "AI generate workflow" (robot icon), also gated on workflow
    modifiability and disabled while a conversion is in flight, opens the
    import modal.
    - "Expand Jupyter notebook" (Jupyter logo), shown only when the current
    workflow has a stored notebook (`jupyterNotebookExists$`), reopens the
    panel.
    - `openImportNotebookModal()` opens `NotebookImportModalComponent` (from
    #7314) with `NzModalService`. The modal collects the `.ipynb` file and
    the LiteLLM model and returns them to the menu.
    - Generating overwrites the current workflow. The menu shows a
    confirmation only when the current workflow already has content
    (operators or comment boxes). The confirmation is centered over the
    modal, and the modal stays open if the user cancels so the file and
    model selection is preserved; approving, or an empty workflow, starts
    the import.
    - `onClickImportNotebook(file, model)` runs the generation:
    - validates the `.ipynb` extension, then emits `setWaitingForLLM(true)`
    to show the overlay
      - parses the notebook and tags each cell with a `metadata.uuid`
    - calls `sendToAIGenerateWorkflow(...)` to get the generated workflow
    content and the cell to operator mapping
    - always overwrites the current workflow by reusing its wid so
    `persistWorkflow(...)` updates that row in place (a never saved workflow
    becomes a new row), which avoids leaving a duplicate "Untitled workflow"
      - persists the notebook and mapping via `storeNotebookAndMapping(...)`
    - reloads the workflow onto the live canvas and runs auto layout on the
    generated operators
    - shows the panel: when the wid is unchanged it sends the notebook and,
    only if the send succeeded, opens the panel directly; when a new row was
    created it points the URL at the generated workflow so
    `JupyterPanelService.init()` sends the notebook and opens the panel
    (avoids a double "sent to Jupyter" toast)
    - clears the overlay at every terminal state, including file read
    errors, parse failures, invalid notebooks, and LLM errors
    
    **Workspace (`workspace.component.{ts,html,scss}`)**
    - Hosts `<texera-jupyter-notebook-panel>` (component landed in #5271,
    now on main).
    - Adds a loading overlay driven by the menu's `setWaitingForLLM` output,
    with an `mm:ss` elapsed time stopwatch. The interval is cleared on
    component destroy and reset if a new conversion starts.
    
    **Assets and licensing**
    - `jupyter-logo.svg`: the Project Jupyter logo mark (BSD 3-Clause, from
    jupyter/design), used as the expand button icon.
    - `.licenserc.yaml`: excludes `jupyter-logo.svg` from the license header
    check, since SVG assets carry no Apache header.
    - `frontend/LICENSE-binary`: adds `jupyter-logo.svg` under the Project
    Jupyter logo attribution (BSD 3-Clause).
    
    The import modal component, its diagram image, and that diagram's
    attributions (Project Jupyter logo and Google Material Symbols icons)
    are in #<modal-PR>.
    
    ### Demo
    
    <img width="423" height="185" alt="Screenshot 2026-08-03 at 2 15 49 PM"
    
src="https://github.com/user-attachments/assets/fe6706bf-ee2e-4933-a140-c316e07f1f89";
    />
    
    New menu buttons
    
    <img width="1512" height="838" alt="image"
    
src="https://github.com/user-attachments/assets/bc7c1e5a-f90f-431b-91e9-076b7002fc8a";
    />
    
    Import notebook popup
    
    <img width="1511" height="835" alt="image"
    
src="https://github.com/user-attachments/assets/d4e6c19f-269c-431d-9306-6abba6a5d0b0";
    />
    
    Waiting for LLM spinner
    
    <img width="1512" height="836" alt="image"
    
src="https://github.com/user-attachments/assets/a3020b94-87bc-49e9-b2b3-ade1c3c8e6f2";
    />
    
    Workflow canvas with a Jupyter notebook panel
    
    
    
https://github.com/user-attachments/assets/52b901c7-3a27-406c-b251-6e45f7236fb5
    
    Mapping highlighting from operator to cell and vice versa
    
    ### Any related issues, documentation, discussions?
    Closes #4427
    Parent issue #4301
    Depends on #7314 (the import modal component) and #5271 (the Jupyter
    notebook panel), both required on `main` before this merges.
    
    ### How was this PR tested?
    - Added unit specs in `menu.component.spec.ts` and
    `workspace.component.spec.ts`.
    
    #### Manual testing
    `npx ng test` for the menu and workspace specs. The menu spec covers
    opening the modal component, the overwrite confirmation flow (empty
    workflow, confirm, and cancel), the generation pipeline across its
    success and error paths, and the two toolbar button clicks. The
    workspace spec covers the elapsed time stopwatch and interval cleanup.
    Manual end to end on a branch with the dependency PRs present: enabled
    the flag, opened the workspace menu, clicked AI generate workflow,
    picked a sample `.ipynb` and a LiteLLM model (auth is via the user's
    Texera JWT, no API key input), confirmed the overwrite prompt, and
    watched the stopwatch while the conversion ran. The generated workflow
    appeared in the editor with `PythonUDFV2` operators auto laid out, the
    panel opened with the notebook in the embedded JupyterLab, clicking an
    operator scrolled its notebook cell into view, and clicking a cell
    highlighted its operators.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    Generated-by: Claude Code (Claude Opus 4.8)
    
    ---------
    
    Co-authored-by: Meng Wang <[email protected]>
---
 .licenserc.yaml                                    |   1 +
 frontend/LICENSE-binary                            |   1 +
 .../jupyter-notebook-panel.component.scss          |  10 +-
 .../workspace/component/menu/menu.component.html   |  20 +
 .../workspace/component/menu/menu.component.scss   |   6 +
 .../component/menu/menu.component.spec.ts          | 455 ++++++++++++++++++++-
 .../app/workspace/component/menu/menu.component.ts | 267 +++++++++++-
 .../workspace/component/workspace.component.html   |  20 +-
 .../workspace/component/workspace.component.scss   |  30 ++
 .../component/workspace.component.spec.ts          |  82 ++++
 .../app/workspace/component/workspace.component.ts |  47 +++
 .../notebook_migration_tool/jupyter-logo.svg       |  42 ++
 12 files changed, 967 insertions(+), 14 deletions(-)

diff --git a/.licenserc.yaml b/.licenserc.yaml
index 4ce2a44884..a9fcde7e3b 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -51,6 +51,7 @@ header:
     - 'frontend/src/assets/svg/operator-view-result.svg'
     - 'frontend/src/assets/svg/operator-reuse-cache-invalid.svg'
     - 'frontend/src/assets/svg/operator-reuse-cache-valid.svg'
+    - 'frontend/src/assets/notebook_migration_tool/jupyter-logo.svg'
     - 
'frontend/src/app/common/type/proto/org/apache/texera/amber/core/virtualidentity.ts'
     - 
'frontend/src/app/common/type/proto/org/apache/texera/amber/core/workflow.ts'
     - 'frontend/src/app/common/type/proto/google/protobuf/descriptor.ts'
diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary
index fcd134da14..61e929c65c 100644
--- a/frontend/LICENSE-binary
+++ b/frontend/LICENSE-binary
@@ -366,6 +366,7 @@ Angular / npm packages:
 Third-party assets:
   - Project Jupyter logo
     frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png
+    frontend/src/assets/notebook_migration_tool/jupyter-logo.svg
     https://github.com/jupyter/design
 
 
--------------------------------------------------------------------------------
diff --git 
a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss
 
b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss
index 8d8bb0631f..3433cfc1ae 100644
--- 
a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss
+++ 
b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss
@@ -19,9 +19,8 @@
 
 .draggable-panel {
   position: absolute;
-  top: 50%;
-  left: 50%;
-  margin-top: 200px;
+  top: 100px;
+  left: 200px;
   width: 660px;
   height: 400px;
   background-color: #fff;
@@ -30,7 +29,8 @@
   border-radius: 5px;
   z-index: 1000;
   resize: both;
-  overflow: auto;
+  // The iframe scrolls its own content; the wrapper must not add a second 
scrollbar.
+  overflow: hidden;
 }
 
 .panel-header {
@@ -74,4 +74,6 @@
   width: 100%;
   height: 100%;
   border: none;
+  // Block removes the inline-element baseline gap that would otherwise 
overflow the wrapper.
+  display: block;
 }
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html 
b/frontend/src/app/workspace/component/menu/menu.component.html
index 33fbf539f6..8b5314b49a 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -137,6 +137,26 @@
             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
+          (click)="onClickExpandJupyterNotebookPanel()"
+          title="expand Jupyter notebook">
+          <img
+            class="jupyter-notebook-icon"
+            src="assets/notebook_migration_tool/jupyter-logo.svg"
+            alt="Jupyter" />
+        </button>
       </nz-space-compact>
       <ng-template #utilities>
         <nz-space-compact>
diff --git a/frontend/src/app/workspace/component/menu/menu.component.scss 
b/frontend/src/app/workspace/component/menu/menu.component.scss
index 1b3c45a52d..cefda1cd79 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.scss
+++ b/frontend/src/app/workspace/component/menu/menu.component.scss
@@ -189,3 +189,9 @@ texera-coeditor-user-icon {
 ::ng-deep .layers-dropdown {
   user-select: none;
 }
+
+.jupyter-notebook-icon {
+  height: 1.1em;
+  width: auto;
+  vertical-align: -0.2em;
+}
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 f766651b26..f35cea2ac1 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
@@ -38,7 +38,10 @@ 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 } from 
"../../../common/service/workflow-persist/workflow-persist.service";
+import {
+  WorkflowPersistService,
+  DEFAULT_WORKFLOW_NAME,
+} 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";
@@ -49,7 +52,13 @@ 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 } from "../../../app-routing.constant";
+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 { GuiConfigService } from "../../../common/service/gui-config.service";
+import { MockGuiConfigService } from 
"../../../common/service/gui-config.service.mock";
 import type { Mocked } from "vitest";
 
 vi.mock("file-saver", () => ({ saveAs: vi.fn() }));
@@ -861,4 +870,446 @@ describe("MenuComponent", () => {
       expect(input.style.width).toMatch(/^\d+px$/);
     });
   });
+
+  describe("expand jupyter notebook panel", () => {
+    it("onClickExpandJupyterNotebookPanel delegates to JupyterPanelService", 
() => {
+      const openSpy = vi
+        .spyOn(TestBed.inject(JupyterPanelService), "openJupyterNotebookPanel")
+        .mockImplementation(() => {});
+
+      component.onClickExpandJupyterNotebookPanel();
+
+      expect(openSpy).toHaveBeenCalled();
+    });
+
+    it("shows the expand-jupyter button only when the flag is on and a 
notebook exists", () => {
+      const button = () => 
fixture.nativeElement.querySelector('button[title="expand Jupyter notebook"]');
+      // commonTestProviders' MockGuiConfigService defaults the flag to false, 
and no notebook exists.
+      expect(button()).toBeNull();
+
+      (TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService).setConfig({
+        pythonNotebookMigrationEnabled: true,
+      });
+      fixture.detectChanges();
+      // Flag on but the current workflow still has no notebook -> hidden.
+      expect(button()).toBeNull();
+
+      (TestBed.inject(JupyterPanelService) as any).jupyterNotebookExists$ = 
of(true);
+      fixture.detectChanges();
+      // Flag on and a notebook exists -> shown.
+      expect(button()).not.toBeNull();
+    });
+
+    it("clicking the expand-jupyter button opens the panel", () => {
+      const openSpy = vi
+        .spyOn(TestBed.inject(JupyterPanelService), "openJupyterNotebookPanel")
+        .mockImplementation(() => {});
+      (TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService).setConfig({
+        pythonNotebookMigrationEnabled: true,
+      });
+      (TestBed.inject(JupyterPanelService) as any).jupyterNotebookExists$ = 
of(true);
+      fixture.detectChanges();
+
+      const button = fixture.nativeElement.querySelector(
+        'button[title="expand Jupyter notebook"]'
+      ) as HTMLButtonElement;
+      button.click();
+
+      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 8e5f16199d..8930d9d0bb 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.ts
@@ -17,11 +17,14 @@
  * under the License.
  */
 
-import { DatePipe, Location, NgIf, NgFor, NgTemplateOutlet } from 
"@angular/common";
-import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from 
"@angular/core";
+import { DatePipe, Location, NgIf, NgFor, NgTemplateOutlet, AsyncPipe } from 
"@angular/common";
+import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, Output, 
EventEmitter } from "@angular/core";
 import { Router, RouterLink } from "@angular/router";
 import { UserService } from "../../../common/service/user/user.service";
-import { WorkflowPersistService } from 
"../../../common/service/workflow-persist/workflow-persist.service";
+import {
+  DEFAULT_WORKFLOW_NAME,
+  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";
@@ -39,14 +42,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 } from "rxjs";
+import { EMPTY, firstValueFrom, of, timer, map } 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 } from "../../../app-routing.constant";
+import { USER_WORKFLOW, USER_WORKSPACE } 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";
@@ -70,6 +73,15 @@ import { NzPopoverDirective } from "ng-zorro-antd/popover";
 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
@@ -117,6 +129,7 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
     NzTooltipDirective,
     DatePipe,
     NzSpaceCompactComponent,
+    AsyncPipe,
   ],
 })
 export class MenuComponent implements OnInit, OnDestroy {
@@ -142,6 +155,9 @@ 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";
@@ -182,7 +198,9 @@ export class MenuComponent implements OnInit, OnDestroy {
     private panelService: PanelService,
     private computingUnitStatusService: ComputingUnitStatusService,
     protected config: GuiConfigService,
-    private router: Router
+    private router: Router,
+    private jupyterPanelService: JupyterPanelService,
+    private notebookMigrationService: NotebookMigrationService
   ) {
     workflowWebsocketService
       .subscribeToEvent("ExecutionDurationUpdateEvent")
@@ -579,6 +597,243 @@ export class MenuComponent implements OnInit, OnDestroy {
     this.workflowActionService.deleteOperatorsAndLinks(allOperatorIDs);
   }
 
+  public get pythonNotebookMigrationEnabled(): boolean {
+    return this.config.env.pythonNotebookMigrationEnabled;
+  }
+
+  // Emits whether the current workflow has an associated Jupyter notebook, 
used to
+  // show the expand button only when there is a notebook to expand.
+  public get jupyterNotebookExists$() {
+    return this.jupyterPanelService.jupyterNotebookExists$;
+  }
+
+  /**
+   * Expand and redisplay the Jupyter notebook panel.
+   */
+  public onClickExpandJupyterNotebookPanel(): void {
+    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 89eeb656f1..78155285d4 100644
--- a/frontend/src/app/workspace/component/workspace.component.html
+++ b/frontend/src/app/workspace/component/workspace.component.html
@@ -21,7 +21,22 @@
   <nz-spin
     [nzSpinning]="isLoading"
     [nzSize]="'large'"
-    nzTip="Loading workflow..."></nz-spin>
+    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>
@@ -29,7 +44,8 @@
 <texera-workflow-editor></texera-workflow-editor>
 <texera-menu
   [writeAccess]="writeAccess"
-  [pid]="pid">
+  [pid]="pid"
+  (setWaitingForLLM)="onWaitingForLLMChanged($event)">
 </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 60fc4abf40..012fa3dfea 100644
--- a/frontend/src/app/workspace/component/workspace.component.scss
+++ b/frontend/src/app/workspace/component/workspace.component.scss
@@ -31,6 +31,12 @@ texera-menu {
   background-color: white;
 }
 
+texera-jupyter-notebook-panel {
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+
 texera-mini-map {
   position: absolute;
   bottom: 0;
@@ -57,3 +63,27 @@ 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 f85294e42a..d930af601f 100644
--- a/frontend/src/app/workspace/component/workspace.component.spec.ts
+++ b/frontend/src/app/workspace/component/workspace.component.spec.ts
@@ -479,4 +479,86 @@ 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 b978107c83..bdc0cdb8a9 100644
--- a/frontend/src/app/workspace/component/workspace.component.ts
+++ b/frontend/src/app/workspace/component/workspace.component.ts
@@ -93,6 +93,10 @@ 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;
 
   /**
@@ -200,6 +204,7 @@ 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();
   }
 
   /**
@@ -320,6 +325,7 @@ export class WorkspaceComponent implements AfterViewInit, 
OnInit, OnDestroy {
         this.registerAutoPersistWorkflow();
       });
   }
+
   onWIDChange() {
     this.workflowActionService
       .workflowMetaDataChanged()
@@ -333,6 +339,7 @@ export class WorkspaceComponent implements AfterViewInit, 
OnInit, OnDestroy {
         this.writeAccess = !metadata.readonly;
       });
   }
+
   updateViewCount() {
     let wid = this.route.snapshot.params.id;
     let uid = this.userService.getCurrentUser()?.uid;
@@ -342,6 +349,7 @@ export class WorkspaceComponent implements AfterViewInit, 
OnInit, OnDestroy {
       .pipe(untilDestroyed(this))
       .subscribe();
   }
+
   public triggerCenter(): void {
     this.workflowActionService.getTexeraGraph().triggerCenterEvent();
   }
@@ -354,4 +362,43 @@ 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")}`;
+  }
 }
diff --git a/frontend/src/assets/notebook_migration_tool/jupyter-logo.svg 
b/frontend/src/assets/notebook_migration_tool/jupyter-logo.svg
new file mode 100644
index 0000000000..4a63571eb5
--- /dev/null
+++ b/frontend/src/assets/notebook_migration_tool/jupyter-logo.svg
@@ -0,0 +1,42 @@
+<svg width="39" height="51" viewBox="0 0 39 51" version="2.0" 
xmlns="http://www.w3.org/2000/svg"; xmlns:xlink="http://www.w3.org/1999/xlink"; 
xmlns:figma="http://www.figma.com/figma/ns";>
+<title>logo-8.svg</title>
+<desc>Created using Figma 0.90</desc>
+<g id="Canvas" transform="translate(-2362 -2281)" figma:type="canvas">
+<g id="logo" style="mix-blend-mode:normal;" figma:type="group">
+<g id="g" style="mix-blend-mode:normal;" figma:type="group">
+<g id="path" style="mix-blend-mode:normal;" figma:type="group">
+<g id="path7 fill" style="mix-blend-mode:normal;" figma:type="vector">
+<use xlink:href="#path0_fill" transform="translate(2393.3 2281.31)" 
style="mix-blend-mode:normal;"/>
+</g>
+</g>
+<g id="path" style="mix-blend-mode:normal;" figma:type="group">
+<g id="path8 fill" style="mix-blend-mode:normal;" figma:type="vector">
+<use xlink:href="#path1_fill" transform="translate(2363.74 2311.98)" 
style="mix-blend-mode:normal;"/>
+</g>
+</g>
+<g id="path" style="mix-blend-mode:normal;" figma:type="group">
+<g id="path9 fill" style="mix-blend-mode:normal;" figma:type="vector">
+<use xlink:href="#path2_fill" transform="translate(2363.73 2285.48)" 
style="mix-blend-mode:normal;"/>
+</g>
+</g>
+<g id="path" style="mix-blend-mode:normal;" figma:type="group">
+<g id="path10 fill" style="mix-blend-mode:normal;" figma:type="vector">
+<use xlink:href="#path3_fill" transform="translate(2363.8 2323.81)" 
style="mix-blend-mode:normal;"/>
+</g>
+</g>
+<g id="path" style="mix-blend-mode:normal;" figma:type="group">
+<g id="path11 fill" style="mix-blend-mode:normal;" figma:type="vector">
+<use xlink:href="#path4_fill" transform="translate(2362.36 2286.06)" 
style="mix-blend-mode:normal;"/>
+</g>
+</g>
+</g>
+</g>
+</g>
+<defs>
+<path id="path0_fill" d="M 5.89353 2.844C 5.91889 3.43165 5.77085 4.01367 
5.46815 4.51645C 5.16545 5.01922 4.72168 5.42015 4.19299 5.66851C 3.6643 
5.91688 3.07444 6.00151 2.49805 5.91171C 1.92166 5.8219 1.38463 5.5617 0.954898 
5.16401C 0.52517 4.76633 0.222056 4.24903 0.0839037 3.67757C -0.0542483 3.10611 
-0.02123 2.50617 0.178781 1.95364C 0.378793 1.4011 0.736809 0.920817 1.20754 
0.573538C 1.67826 0.226259 2.24055 0.0275919 2.82326 0.00267229C 3.60389 
-0.0307115 4.36573 0.249789 4.94142 [...]
+<path id="path1_fill" d="M 18.2646 7.13411C 10.4145 7.13411 3.55872 4.2576 0 
0C 1.32539 3.8204 3.79556 7.13081 7.0686 9.47303C 10.3417 11.8152 14.2557 
13.0734 18.269 13.0734C 22.2823 13.0734 26.1963 11.8152 29.4694 9.47303C 
32.7424 7.13081 35.2126 3.8204 36.538 0C 32.9705 4.2576 26.1148 7.13411 18.2646 
7.13411Z"/>
+<path id="path2_fill" d="M 18.2733 5.93931C 26.1235 5.93931 32.9793 8.81583 
36.538 13.0734C 35.2126 9.25303 32.7424 5.94262 29.4694 3.6004C 26.1963 1.25818 
22.2823 0 18.269 0C 14.2557 0 10.3417 1.25818 7.0686 3.6004C 3.79556 5.94262 
1.32539 9.25303 0 13.0734C 3.56745 8.82463 10.4232 5.93931 18.2733 5.93931Z"/>
+<path id="path3_fill" d="M 7.42789 3.58338C 7.46008 4.3243 7.27355 5.05819 
6.89193 5.69213C 6.51031 6.32607 5.95075 6.83156 5.28411 7.1446C 4.61747 
7.45763 3.87371 7.56414 3.14702 7.45063C 2.42032 7.33712 1.74336 7.0087 1.20184 
6.50695C 0.660328 6.0052 0.27861 5.35268 0.105017 4.63202C -0.0685757 3.91135 
-0.0262361 3.15494 0.226675 2.45856C 0.479587 1.76217 0.931697 1.15713 1.52576 
0.720033C 2.11983 0.282935 2.82914 0.0334395 3.56389 0.00313344C 4.54667 
-0.0374033 5.50529 0.316706 6.2296 [...]
+<path id="path4_fill" d="M 2.27471 4.39629C 1.84363 4.41508 1.41671 4.30445 
1.04799 4.07843C 0.679268 3.8524 0.385328 3.52114 0.203371 3.12656C 0.0214136 
2.73198 -0.0403798 2.29183 0.0258116 1.86181C 0.0920031 1.4318 0.283204 1.03126 
0.575213 0.710883C 0.867222 0.39051 1.24691 0.164708 1.66622 0.0620592C 2.08553 
-0.0405897 2.52561 -0.0154714 2.93076 0.134235C 3.33591 0.283941 3.68792 
0.551505 3.94222 0.90306C 4.19652 1.25462 4.34169 1.67436 4.35935 2.10916C 
4.38299 2.69107 4.17678 3.2586 [...]
+</defs>
+</svg>

Reply via email to