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-8391-dcb25efa9e1d4321b7b99459db4ee777f37cbaff
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 873c91ce70f15266383e82c2e0f74050692c2b84
Author: yangzhang75 <[email protected]>
AuthorDate: Sat Sep 5 05:32:33 2026 +0000

    feat(workflow-form): Form View title bar, save, and modification lock 
(#8391)
    
    ### What changes were proposed in this PR?
    
    Building on the Form View page shell (#8376), this PR adds the title bar
    and the save it needs (parent issue #8011). Frontend only.
    
    * The workflow name, renamable inline exactly as on the operator canvas,
    with the same "Saved at ..." state beside it. Both follow the workflow's
    metadata live through the same `workflowMetaDataChanged` subscription
    the canvas uses, so a rename or save (this page's own, or a co-editor's)
    refreshes the name, its width, and the saved-at state from one place,
    and the two views never drift apart.
    * The debounced save both views share, so an edit in one view is not
    lost in the other. It saves only the workflow this page opened (never
    spawning a stray "Untitled workflow" from a payload with no id),
    preserves a position for every operator (a partial map makes a workflow
    unopenable), saves once more on the way out and before handing back to
    the canvas, and reports a failed save rather than losing it silently.
    * Locks the workflow to read-only on this page
    (`disableWorkflowModification`): the graph and its properties are shown,
    not edited. A later PR's authoring mode makes them editable with write
    access.
    
    The read-only preview, the inputs, running and results are added by
    later PRs.
    
    #### Screenshot
    
    The title bar (renamable name, "Saved at ..." state, the Canvas / Form
    View switch) on the read-only page:
    <img width="1280" height="86" alt="Screenshot 2026-09-03 at 11 18 10 PM"
    
src="https://github.com/user-attachments/assets/b1fa29a8-8b08-4b0c-bfda-51e62d8628f6";
    />
    
    ### Any related issues, documentation, discussions?
    
    Part of the Form View feature (parent issue #8011); stacked on #8376
    (the page shell). Until #8376 merges, this PR's diff includes that PR's
    commit as well. close #8019
    
    ### How was this PR tested?
    
    Unit tests (vitest): `workflow-form.component.spec.ts` (21 tests). As
    with the shell, the component injects heavy services and child
    components, so its spec constructs it directly with the shared harness's
    mocks rather than through TestBed; template rendering is therefore not
    exercised, but the component and the harness are at 100% of the
    TypeScript lines this PR adds. The template compiles cleanly under `ng
    build`.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Co-authored with Claude (Anthropic), reviewed line by line by the
    author before submission.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../workflow-form/workflow-form.component.html     |  16 +-
 .../workflow-form/workflow-form.component.scss     |  23 ++-
 .../workflow-form/workflow-form.component.spec.ts  | 206 ++++++++++++++++++++-
 .../workflow-form/workflow-form.component.ts       | 184 +++++++++++++++++-
 .../workflow-form/workflow-form.rendered.spec.ts   |  40 +++-
 .../workflow-form/workflow-form.spec-harness.ts    |  25 ++-
 6 files changed, 468 insertions(+), 26 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
index 618e8c0e1c..a1402b04f2 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -19,14 +19,24 @@
 
 <!-- The operator canvas's title row, rebuilt here to the same measurements: 
same
      height, same rule underneath, same full-bleed width, and the view switch 
pinned to
-     the same right inset ahead of the same user icon. The name is shown here; 
making it
-     editable (with the same rename/save the canvas gives) is the next PR. -->
+     the same right inset ahead of the same user icon. The content below is 
padded and
+     centred, which is why the switch cannot live down there -- it would stop 
at the
+     column edge and never line up with the other view. -->
 <div class="pc-topbar">
   <nz-avatar
     *ngIf="wid"
     class="wid"
     [nzText]="wid.toString()"></nz-avatar>
-  <span class="wf-name">{{ workflowName || "Untitled workflow" }}</span>
+  <!-- An input, not a label: the workflow can be renamed from either view, 
with the same
+       control in the same place. -->
+  <input
+    class="wf-name"
+    [(ngModel)]="workflowName"
+    (change)="onRenameWorkflow()"
+    placeholder="Untitled workflow" />
+  <!-- Editing here writes to the same workflow, so it earns the same 
reassurance the
+       operator canvas gives: the moment it was last saved. -->
+  <span class="saved-state">{{ autoSaveState }}</span>
   <span class="topbar-spacer"></span>
   <div class="view-switch">
     <button
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
index 29960a1972..c166254975 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
@@ -22,6 +22,7 @@ $blue: #1890ff;
 $text: rgba(0, 0, 0, 0.85);
 $text-2: rgba(0, 0, 0, 0.45);
 $divider: #f0f0f0;
+$shell: #fafafa;
 
 :host {
   display: block;
@@ -64,16 +65,32 @@ $divider: #f0f0f0;
      padding; matching that is what lines the two titles up. */
   gap: 0;
 
-  /* Every value here is the operator canvas's .workflow-name, so the two 
title rows read
-     as the same row: the same size, weight and padding. Shown here; the 
editable input is
-     the next PR. */
+  /* An input, not a label: the workflow can be renamed from either view. 
Every value here is
+     the operator canvas's .workflow-name, so the two title rows read as the 
same row --
+     content-box sizing with the same padding, no border, chromeless until you 
reach for it,
+     and a width set from the text by the same measure-in-a-span trick. */
   .wf-name {
     min-width: 100px;
     max-width: 800px;
     font-size: 18px;
     font-weight: 400;
     padding: 2px 8px;
+    border: none;
+    box-sizing: content-box;
+    background: none;
     color: $text;
+    outline: none;
+
+    &:hover {
+      background: $shell;
+    }
+  }
+
+  /* Immediately after the name, in the body colour and size of ordinary text, 
exactly as on
+     the operator canvas; the name field's own padding makes the gap. */
+  .saved-state {
+    color: $text;
+    white-space: nowrap;
   }
 
   .topbar-spacer {
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
index 76732c814d..ebd55cf3f0 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
@@ -18,7 +18,7 @@
  */
 
 import { Router } from "@angular/router";
-import { throwError } from "rxjs";
+import { of, throwError } from "rxjs";
 
 import { WorkflowFormComponent } from "./workflow-form.component";
 import { setupHarness, formViewWorkflow } from "./workflow-form.spec-harness";
@@ -26,10 +26,11 @@ import { USER_WORKFLOW, USER_WORKSPACE } from 
"../../../app-routing.constant";
 import { DefaultView } from 
"../../../dashboard/type/workflow-metadata.interface";
 
 /**
- * These exercise the shell's own decisions -- what a reader is shown and 
where an ordinary
- * workflow is sent -- without standing up the JointJS canvas. The component 
is built directly
- * (not through TestBed) with the shared spec harness's mocks; the title bar, 
save and preview
- * are added, with their own tests, by later PRs.
+ * These exercise the page's own decisions -- what a reader is shown, where an 
ordinary
+ * workflow is sent, and how the title bar renames and saves -- without 
standing up the JointJS
+ * canvas. The component is built directly (not through TestBed) with the 
shared spec harness's
+ * mocks; the read-only preview, inputs, running and results are added, with 
their own tests, by
+ * later PRs.
  */
 describe("WorkflowFormComponent", () => {
   let component: WorkflowFormComponent;
@@ -54,6 +55,8 @@ describe("WorkflowFormComponent", () => {
       h.cdr as any,
       h.computingUnitStatusService as any,
       h.workflowConsoleService as any,
+      h.host as any,
+      h.datePipe as any,
       h.config as any
     );
     return component;
@@ -145,4 +148,197 @@ describe("WorkflowFormComponent", () => {
       expect(h.workflowResultService.clearResults).toHaveBeenCalled();
     });
   });
+
+  describe("title bar and saving", () => {
+    const enableSave = () => {
+      h.userService.isLogin.mockReturnValue(true);
+      h.workflowPersistService.isWorkflowPersistEnabled.mockReturnValue(true);
+    };
+
+    it("shows the last-saved time from the workflow's metadata", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      expect(component.autoSaveState).toBe("Saved at 01/01/2026 00:00:00");
+    });
+
+    it("shows no saved state when the workflow has never been saved", () => {
+      workflowActionService.getWorkflowMetadata = () => ({ name: "x", 
lastModifiedTime: undefined });
+
+      build(formViewWorkflow).ngOnInit();
+
+      expect(component.autoSaveState).toBe("");
+    });
+
+    it("renames through the workflow action service and saves", () => {
+      enableSave();
+      build(formViewWorkflow).ngOnInit();
+      component.workflowName = "New name";
+
+      component.onRenameWorkflow();
+
+      expect(workflowActionService.setWorkflowName).toHaveBeenCalledWith("New 
name");
+      expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+    });
+
+    // The title bar is refreshed from one place: a rename or save -- here or 
by a co-editor --
+    // updates the shown name and the saved-at state, so the two views never 
drift apart. This
+    // is also where onRenameWorkflow's normalised name is read back.
+    it("follows the workflow metadata: refreshes the name and saved state when 
it changes", () => {
+      vi.useFakeTimers();
+      build(formViewWorkflow).ngOnInit();
+      component.workflowName = "stale";
+      workflowActionService.getWorkflowMetadata = () => ({ name: "Renamed", 
lastModifiedTime: 1767225600000 });
+
+      h.workflowMetaDataChangedStream.next(undefined);
+      vi.runAllTimers();
+
+      expect(component.workflowName).toBe("Renamed");
+      expect(component.autoSaveState).toBe("Saved at 01/01/2026 00:00:00");
+      vi.useRealTimers();
+    });
+
+    it("persists the workflow, filling in a position for every operator", () 
=> {
+      enableSave();
+      workflowActionService.getWorkflow.mockReturnValue({
+        wid: 7,
+        content: {
+          operators: [{ operatorID: "op-1" }, { operatorID: "op-2" }],
+          operatorPositions: { "op-1": { x: 5, y: 6 } },
+        },
+      });
+      build(formViewWorkflow).ngOnInit();
+
+      (component as any).save();
+
+      const saved = 
workflowPersistService.persistWorkflow.mock.calls.at(-1)[0];
+      expect(saved.content.operatorPositions).toEqual({ "op-1": { x: 5, y: 6 
}, "op-2": { x: 0, y: 0 } });
+    });
+
+    // The graph is read-only here, but a co-editor can still move operators 
on the canvas; a save
+    // must carry those live positions, not revert them to where they sat when 
this page opened.
+    it("saves the live positions, not the load-time snapshot", () => {
+      enableSave();
+      build({ ...formViewWorkflow, content: { operatorPositions: { "op-1": { 
x: 1, y: 1 } } } }).ngOnInit();
+      // a co-editor has since dragged op-1; the shared graph reflects the new 
spot
+      workflowActionService.getWorkflow.mockReturnValue({
+        wid: 7,
+        content: { operators: [{ operatorID: "op-1" }], operatorPositions: { 
"op-1": { x: 9, y: 9 } } },
+      });
+
+      (component as any).save();
+
+      const saved = 
workflowPersistService.persistWorkflow.mock.calls.at(-1)[0];
+      expect(saved.content.operatorPositions).toEqual({ "op-1": { x: 9, y: 9 } 
});
+    });
+
+    // The canvas advances "Saved at ..." by feeding the persist response back 
into the metadata;
+    // the form must do the same, or the saved-at state never moves past the 
moment it opened.
+    it("feeds the persist response back into the workflow metadata", () => {
+      enableSave();
+      build(formViewWorkflow).ngOnInit();
+      const updated = { wid: 7, name: "scGPT", lastModifiedTime: 999, content: 
{} };
+      workflowPersistService.persistWorkflow.mockReturnValue(of(updated));
+
+      (component as any).save();
+
+      
expect(workflowActionService.setWorkflowMetadata).toHaveBeenCalledWith(updated);
+    });
+
+    it("does not save when the user is not logged in", () => {
+      build(formViewWorkflow).ngOnInit();
+      workflowPersistService.persistWorkflow.mockClear();
+
+      (component as any).save();
+
+      expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+    });
+
+    it("does not save when persistence is disabled", () => {
+      h.userService.isLogin.mockReturnValue(true);
+      build(formViewWorkflow).ngOnInit();
+      workflowPersistService.persistWorkflow.mockClear();
+
+      (component as any).save();
+
+      expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+    });
+
+    it("does not save a workflow that is not the one this page opened", () => {
+      enableSave();
+      workflowActionService.getWorkflow.mockReturnValue({ wid: 99, content: { 
operators: [], operatorPositions: {} } });
+      build(formViewWorkflow).ngOnInit();
+      workflowPersistService.persistWorkflow.mockClear();
+
+      (component as any).save();
+
+      expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+    });
+
+    it("reports a failed save so a lost edit is not silent", () => {
+      enableSave();
+      build(formViewWorkflow).ngOnInit();
+      // set after build(): build()'s useWorkflow() resets the persist mock
+      workflowPersistService.persistWorkflow.mockReturnValue(throwError(() => 
new Error("no")));
+
+      (component as any).save();
+
+      expect(h.notificationService.error).toHaveBeenCalled();
+    });
+
+    it("saves on any workflow change, debounced", () => {
+      vi.useFakeTimers();
+      enableSave();
+      build(formViewWorkflow).ngOnInit();
+      workflowPersistService.persistWorkflow.mockClear();
+
+      h.workflowChangedStream.next(undefined);
+      vi.runAllTimers();
+
+      expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+      vi.useRealTimers();
+    });
+
+    it("saves before handing over to the operator canvas", () => {
+      enableSave();
+      build(formViewWorkflow).ngOnInit();
+      workflowPersistService.persistWorkflow.mockClear();
+
+      component.openRegularCanvas();
+
+      expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+    });
+
+    it("saves once more on the way out", () => {
+      enableSave();
+      build(formViewWorkflow).ngOnInit();
+      workflowPersistService.persistWorkflow.mockClear();
+
+      component.ngOnDestroy();
+
+      expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+    });
+
+    it("measures the name field after load, and no-ops when it is not in the 
DOM", () => {
+      vi.useFakeTimers();
+      const query = vi.spyOn(h.host.nativeElement, "querySelector");
+      build(formViewWorkflow).ngOnInit();
+
+      vi.runAllTimers();
+
+      expect(query).toHaveBeenCalledWith("input.wf-name");
+      vi.useRealTimers();
+    });
+
+    it("stops a deferred name measurement once the page is gone", () => {
+      vi.useFakeTimers();
+      build(formViewWorkflow).ngOnInit();
+      const query = vi.spyOn(h.host.nativeElement, "querySelector");
+      component.ngOnDestroy();
+
+      vi.runAllTimers();
+
+      expect(query).not.toHaveBeenCalled();
+      vi.useRealTimers();
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
index 3d8f2b8f8c..c1db653f03 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -17,15 +17,18 @@
  * under the License.
  */
 
-import { ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit } from 
"@angular/core";
-import { CommonModule } from "@angular/common";
+import { ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, 
OnInit } from "@angular/core";
+import { CommonModule, DatePipe } from "@angular/common";
+import { FormsModule } from "@angular/forms";
 import { ActivatedRoute, Router } from "@angular/router";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { NzAvatarModule } from "ng-zorro-antd/avatar";
 import { UserIconComponent } from 
"../../../dashboard/component/user/user-icon/user-icon.component";
 import { forkJoin } from "rxjs";
+import { debounceTime } from "rxjs/operators";
 
 import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
+import { Workflow, WorkflowContent } from "../../../common/type/workflow";
 import { ComputingUnitStatusService } from 
"../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
 import { WorkflowPersistService } from 
"../../../common/service/workflow-persist/workflow-persist.service";
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
@@ -36,27 +39,42 @@ import { WorkflowActionService } from 
"../../service/workflow-graph/model/workfl
 import { GuiConfigService } from "../../../common/service/gui-config.service";
 import { WorkflowConsoleService } from 
"../../service/workflow-console/workflow-console.service";
 import { WorkflowResultService } from 
"../../service/workflow-result/workflow-result.service";
+import { Point } from "../../types/workflow-common.interface";
 import { CoeditorUserIconComponent } from 
"../menu/coeditor-user-icon/coeditor-user-icon.component";
 import { CoeditorPresenceService } from 
"../../service/workflow-graph/model/coeditor-presence.service";
+import { SAVE_DEBOUNCE_TIME_IN_MS } from "../workspace.component";
 
 /**
- * The Form View: a second way to use a workflow. This PR lays down the page 
shell -- behind
- * the feature flag it loads the workflow the URL names, shows it read-only, 
and hands back to
- * the operator canvas. The title bar's rename/save, the read-only preview, 
the inputs, running
- * and results are added on top by later PRs. A view, not a new object: it 
opens the same
- * workflow the canvas does.
+ * The Form View: a second way to use a workflow. Building on the page shell, 
this PR adds the
+ * title bar -- the workflow name (renamable, exactly as on the operator 
canvas), its "Saved
+ * at ..." state, and the debounced save that both views share so an edit in 
one view is not
+ * lost in the other. The read-only preview, the inputs, running and results 
are added by later
+ * PRs. A view, not a new object: it opens the same workflow the canvas does.
  */
 @UntilDestroy()
 @Component({
   selector: "texera-workflow-form",
   templateUrl: "./workflow-form.component.html",
   styleUrls: ["./workflow-form.component.scss"],
-  imports: [CommonModule, NzAvatarModule, UserIconComponent, 
CoeditorUserIconComponent],
+  imports: [CommonModule, FormsModule, NzAvatarModule, UserIconComponent, 
CoeditorUserIconComponent],
 })
 export class WorkflowFormComponent implements OnInit, OnDestroy {
   public wid?: number;
   public workflowName = "";
   public loading = true;
+  /** "Saved at …", worded and formatted exactly as on the operator canvas. */
+  public autoSaveState = "";
+
+  /** Set on teardown so deferred callbacks stop touching a view that is gone. 
*/
+  private destroyed = false;
+
+  /**
+   * Operator positions as loaded, kept only as a fallback: a save writes the 
live positions
+   * from the shared model (a co-editor's drags included), and falls back to 
this snapshot, then
+   * origin, if the live map is ever missing an operator -- so a save never 
drops an operator's
+   * position, and never overwrites a co-editor's move with a stale one.
+   */
+  private storedPositions: { [operatorID: string]: Point } = {};
 
   constructor(
     // Public for the template: shows the same live collaborator avatars as 
the canvas.
@@ -73,6 +91,8 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     private cdr: ChangeDetectorRef,
     private computingUnitStatusService: ComputingUnitStatusService,
     private workflowConsoleService: WorkflowConsoleService,
+    private host: ElementRef<HTMLElement>,
+    private datePipe: DatePipe,
     private config: GuiConfigService
   ) {}
 
@@ -108,11 +128,16 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
           // a per-workflow switch -- and bounce a later PR's canvas-to-form 
switch straight
           // back for any canvas-default workflow.
           this.workflowName = workflow.name;
+          this.storedPositions = { ...(workflow.content?.operatorPositions ?? 
{}) };
           this.workflowActionService.setNewSharedModel(wid, 
this.userService.getCurrentUser());
           this.workflowActionService.reloadWorkflow(workflow);
           // The workflow is shown, not edited, from here: dragging operators 
around or
           // deleting them belongs to the operator canvas.
           this.applyEditability();
+          this.refreshSavedState();
+          this.later(() => this.adjustWorkflowNameWidth(), 0);
+          this.registerMetadataRefresh();
+          this.registerAutoPersist();
           this.loading = false;
           this.cdr.detectChanges();
         },
@@ -133,6 +158,70 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     this.workflowActionService.disableWorkflowModification();
   }
 
+  /**
+   * Size the name field to its text, the way the operator canvas does, so 
what follows
+   * it starts at the same place in both views instead of after a fixed-width 
box.
+   */
+  private adjustWorkflowNameWidth(): void {
+    const input = 
this.host.nativeElement.querySelector<HTMLInputElement>("input.wf-name");
+    if (!input) {
+      return;
+    }
+    /* v8 ignore start -- font-metrics DOM measuring; jsdom has no layout */
+    const probe = document.createElement("span");
+    probe.style.visibility = "hidden";
+    probe.style.position = "absolute";
+    probe.style.whiteSpace = "pre";
+    probe.style.font = getComputedStyle(input).font;
+    probe.textContent = input.value || input.placeholder;
+    document.body.appendChild(probe);
+    input.style.width = `${Math.min(probe.offsetWidth + 20, 800)}px`;
+    document.body.removeChild(probe);
+    /* v8 ignore stop */
+  }
+
+  private refreshSavedState(): void {
+    const lastModified = 
this.workflowActionService.getWorkflowMetadata()?.lastModifiedTime;
+    this.autoSaveState =
+      lastModified === undefined
+        ? ""
+        : "Saved at " +
+          (this.datePipe.transform(
+            lastModified,
+            "MM/dd/yyyy HH:mm:ss",
+            Intl.DateTimeFormat().resolvedOptions().timeZone,
+            "en"
+          ) ?? "");
+  }
+
+  /**
+   * Renaming here is the same edit as renaming on the operator canvas: commit 
the name and
+   * save. The title bar itself -- the read-back (normalised) name and its 
width -- is
+   * refreshed from the metadata subscription below, the single place this 
page's own rename
+   * and a co-editor's both flow through.
+   */
+  public onRenameWorkflow(): void {
+    this.workflowActionService.setWorkflowName(this.workflowName);
+    this.save();
+  }
+
+  /**
+   * Keep the title bar in step with the workflow's metadata, exactly as the 
operator canvas
+   * does: a rename or a save -- this page's own or a co-editor's -- refreshes 
the name, its
+   * width, and the "Saved at ..." state from one place, so the two views 
never drift apart.
+   */
+  private registerMetadataRefresh(): void {
+    this.workflowActionService
+      .workflowMetaDataChanged()
+      // The same 100ms the operator canvas debounces its title-bar refresh by.
+      .pipe(debounceTime(100), untilDestroyed(this))
+      .subscribe(() => {
+        this.workflowName = 
this.workflowActionService.getWorkflowMetadata()?.name ?? "";
+        this.later(() => this.adjustWorkflowNameWidth(), 0);
+        this.refreshSavedState();
+      });
+  }
+
   /**
    * Switch to the operator canvas with a full page load, not a route. The two 
views share
    * root-level singletons (the graph, the Yjs shared model, the CU 
connection); handing
@@ -140,18 +229,97 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
    * of yourself, broken runs. A fresh document is the reliable handover.
    */
   public openRegularCanvas(): void {
+    this.save();
     /* v8 ignore start -- full-document navigation; jsdom cannot navigate */
     window.location.href = `${USER_WORKSPACE}/${this.wid}`;
     /* v8 ignore stop */
   }
 
+  /**
+   * Save the same way the operator canvas does. Both views edit one workflow, 
so the
+   * form has to write through the same debounced persist -- otherwise an 
author's
+   * setup, or a value someone filled in, would be gone on the next visit.
+   */
+  private registerAutoPersist(): void {
+    this.workflowActionService
+      .workflowChanged()
+      .pipe(debounceTime(SAVE_DEBOUNCE_TIME_IN_MS), untilDestroyed(this))
+      .subscribe(() => this.save());
+  }
+
+  /**
+   * Save the workflow this page opened, and only that one. The persist 
endpoint creates a
+   * workflow when the payload has no id, so saving whatever the graph holds 
would spawn
+   * stray "Untitled workflow" rows when the page is left before its workflow 
loaded.
+   */
+  private save(): void {
+    if (!this.userService.isLogin() || 
!this.workflowPersistService.isWorkflowPersistEnabled()) {
+      return;
+    }
+    const workflow = this.workflowActionService.getWorkflow();
+    if (workflow.wid === undefined || workflow.wid !== this.wid) {
+      return;
+    }
+    const preserved: Workflow = {
+      ...workflow,
+      content: { ...workflow.content, operatorPositions: 
this.positionsToSave(workflow.content) },
+    };
+    // On the way out the subscription must NOT be tied to this component: 
ngOnDestroy
+    // calls save(), and untilDestroyed would tear the subscription down as 
part of the
+    // very same destroy sequence, aborting the request that was the point of 
the call.
+    const persist = this.workflowPersistService.persistWorkflow(preserved);
+    // The `destroyed` branch deliberately omits untilDestroyed (see above); 
the persist call
+    // is a one-shot HTTP request that completes on its own, so it needs no 
teardown operator.
+    // eslint-disable-next-line rxjs-angular/prefer-takeuntil
+    (this.destroyed ? persist : persist.pipe(untilDestroyed(this))).subscribe({
+      // Feed the saved workflow back, exactly as the operator canvas does: 
this advances
+      // lastModifiedTime (and the normalised name), and the metadata 
subscription then repaints
+      // the title bar -- so "Saved at ..." moves past the moment the page 
opened.
+      next: updatedWorkflow => 
this.workflowActionService.setWorkflowMetadata(updatedWorkflow),
+      // A save that fails silently is the worst thing this page can do: the 
author walks
+      // away believing the form they just built is stored.
+      error: () => this.notificationService.error("Could not save. Your latest 
changes are not stored yet."),
+    });
+  }
+
+  /**
+   * A position for every operator: the live one from the shared model (what a 
co-editor's drag
+   * has set), else the load-time snapshot, else origin. Preferring live saves 
what is current
+   * rather than overwriting moves with a stale copy; the fallbacks keep the 
guarantee that every
+   * operator has a position, since loading throws on one that does not.
+   */
+  private positionsToSave(content: WorkflowContent): { [operatorID: string]: 
Point } {
+    const positions: { [operatorID: string]: Point } = {};
+    for (const operator of content.operators) {
+      positions[operator.operatorID] = 
content.operatorPositions?.[operator.operatorID] ??
+        this.storedPositions[operator.operatorID] ?? { x: 0, y: 0 };
+    }
+    return positions;
+  }
+
+  /**
+   * Run after a short delay, unless the page is gone by then: the callback 
touches the view,
+   * and detectChanges on a destroyed view throws -- reachable by navigating 
away while the
+   * name field is still waiting to be measured.
+   */
+  private later(fn: () => void, delayMs = 0): void {
+    setTimeout(() => {
+      if (!this.destroyed) {
+        fn();
+      }
+    }, delayMs);
+  }
+
   /**
    * Tear down exactly what the operator canvas tears down: both views drive 
the same
    * singleton services, so anything left bound here follows the user to the 
next page
    * (the symptom was a frozen canvas after a visit -- the old shared model 
still attached).
+   * On the way out, save once more so a last edit is not lost.
    */
   @HostListener("window:beforeunload")
   ngOnDestroy(): void {
+    this.destroyed = true;
+    this.save();
     this.workflowActionService.clearWorkflow();
     this.computingUnitStatusService.disconnect();
     this.executeWorkflowService.resetExecutionAndWorkers();
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
index 90285492d4..af7ba64f07 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
@@ -17,9 +17,10 @@
  * under the License.
  */
 
+import { DatePipe } from "@angular/common";
 import { ComponentFixture, TestBed } from "@angular/core/testing";
 import { ActivatedRoute, Router } from "@angular/router";
-import { of, Subject } from "rxjs";
+import { EMPTY, of, Subject } from "rxjs";
 
 import { WorkflowFormComponent } from "./workflow-form.component";
 import { UserIconComponent } from 
"../../../dashboard/component/user/user-icon/user-icon.component";
@@ -78,17 +79,29 @@ describe("WorkflowFormComponent (rendered template)", () => 
{
             disableWorkflowModification: vi.fn(),
             clearWorkflow: vi.fn(),
             getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: 
undefined }),
+            getWorkflow: () => ({ wid: 7, content: { operators: [], 
operatorPositions: {} } }),
+            setWorkflowName: vi.fn(),
+            workflowChanged: () => EMPTY,
+            workflowMetaDataChanged: () => EMPTY,
+          },
+        },
+        {
+          provide: WorkflowPersistService,
+          useValue: {
+            retrieveWorkflow: () => workflow$,
+            isWorkflowPersistEnabled: () => false,
+            persistWorkflow: () => of({}),
           },
         },
-        { provide: WorkflowPersistService, useValue: { retrieveWorkflow: () => 
workflow$ } },
         { provide: OperatorMetadataService, useValue: { getOperatorMetadata: 
() => of({}) } },
         { provide: ExecuteWorkflowService, useValue: { 
resetExecutionAndWorkers: vi.fn() } },
         { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } 
},
         { provide: NotificationService, useValue: { error: vi.fn() } },
-        { provide: UserService, useValue: { getCurrentUser: () => undefined } 
},
+        { provide: UserService, useValue: { getCurrentUser: () => undefined, 
isLogin: () => false } },
         { provide: ComputingUnitStatusService, useValue: { disconnect: vi.fn() 
} },
         { provide: WorkflowConsoleService, useValue: { clearConsoleMessages: 
vi.fn() } },
         { provide: GuiConfigService, useValue: { env: { formViewEnabled: true 
} } },
+        DatePipe,
       ],
     }).compileComponents();
     fixture = TestBed.createComponent(WorkflowFormComponent);
@@ -103,13 +116,30 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
 
   beforeEach(configure);
 
-  it("renders the workflow's avatar and name in the title row", () => {
+  it("renders the workflow's avatar and name in the title row", async () => {
     fixture.detectChanges(); // ngOnInit -> load()
     finishLoad();
+    // ngModel writes the name into the input on a microtask; let it flush 
before reading.
+    await fixture.whenStable();
+    fixture.detectChanges();
 
     expect(el(".pc-topbar")).not.toBeNull();
     expect(el("nz-avatar.wid")).not.toBeNull();
-    expect(el(".wf-name")?.textContent?.trim()).toBe("scGPT");
+    // The name is an editable input on this slice; its value is the workflow 
name.
+    expect((el("input.wf-name") as HTMLInputElement | 
null)?.value).toBe("scGPT");
+  });
+
+  it("renames the workflow when the name input fires a change", async () => {
+    fixture.detectChanges();
+    finishLoad();
+    await fixture.whenStable();
+    const spy = vi.spyOn(fixture.componentInstance, "onRenameWorkflow");
+    const input = el("input.wf-name") as HTMLInputElement;
+
+    input.value = "Renamed";
+    input.dispatchEvent(new Event("change"));
+
+    expect(spy).toHaveBeenCalled();
   });
 
   it("switches to the operator canvas when the Canvas control is clicked", () 
=> {
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
index b047361535..2fc33a6a19 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { of } from "rxjs";
+import { of, Subject } from "rxjs";
 import { vi } from "vitest";
 
 import { DefaultView } from 
"../../../dashboard/type/workflow-metadata.interface";
@@ -34,6 +34,8 @@ export const formViewWorkflow = { name: "scGPT", defaultView: 
DefaultView.FORM,
  */
 export function setupHarness() {
   const router = { navigate: vi.fn() };
+  const workflowChangedStream = new Subject<unknown>();
+  const workflowMetaDataChangedStream = new Subject<unknown>();
 
   const workflowActionService = {
     resetAsNewWorkflow: vi.fn(),
@@ -42,9 +44,18 @@ export function setupHarness() {
     enableWorkflowModification: vi.fn(),
     disableWorkflowModification: vi.fn(),
     clearWorkflow: vi.fn(),
+    workflowChanged: () => workflowChangedStream.asObservable(),
+    workflowMetaDataChanged: () => 
workflowMetaDataChangedStream.asObservable(),
+    getWorkflow: vi.fn().mockReturnValue({ wid: 7, content: { operators: [], 
operatorPositions: {} } }),
+    getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: 
1767225600000 }),
+    setWorkflowName: vi.fn(),
+    setWorkflowMetadata: vi.fn(),
   };
   const workflowPersistService = {
     retrieveWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)),
+    // Off by default so opening a workflow does not save; the save tests turn 
it on.
+    isWorkflowPersistEnabled: vi.fn().mockReturnValue(false),
+    persistWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)),
   };
   const coeditorPresenceService = { coeditors: [] };
   const route = { snapshot: { params: { id: "7" } } };
@@ -52,16 +63,22 @@ export function setupHarness() {
   const executeWorkflowService = { resetExecutionAndWorkers: vi.fn() };
   const workflowResultService = { clearResults: vi.fn() };
   const notificationService = { error: vi.fn() };
-  const userService = { getCurrentUser: () => undefined };
+  // Not logged in by default so opening a workflow does not save; the save 
tests log in.
+  const userService = { getCurrentUser: () => undefined, isLogin: 
vi.fn().mockReturnValue(false) };
   const cdr = { detectChanges: vi.fn() };
   const computingUnitStatusService = { disconnect: vi.fn() };
   const workflowConsoleService = { clearConsoleMessages: vi.fn() };
+  // The name field is measured off the host; querySelector returns null so 
the measuring
+  // (DOM-layout, jsdom has none) short-circuits.
+  const host = { nativeElement: { querySelector: () => null } };
+  const datePipe = { transform: () => "01/01/2026 00:00:00" };
   const config = { env: { formViewEnabled: true } };
 
   // Point the persist mock at `workflow`; each spec supplies the remaining 
constructor
   // arguments in its own order via the named mocks above.
   const useWorkflow = (workflow: any) => {
     workflowPersistService.retrieveWorkflow.mockReturnValue(of(workflow));
+    workflowPersistService.persistWorkflow.mockReturnValue(of(workflow));
   };
 
   return {
@@ -79,6 +96,10 @@ export function setupHarness() {
     cdr,
     computingUnitStatusService,
     workflowConsoleService,
+    host,
+    datePipe,
     config,
+    workflowChangedStream,
+    workflowMetaDataChangedStream,
   };
 }

Reply via email to