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

commit 644c19fee7e0556ed0325eb26c7bd2fac6bc9acc
Author: yangzhang75 <[email protected]>
AuthorDate: Thu Sep 10 00:01:42 2026 +0000

    feat(workflow-form): show the chosen results (#8441)
    
    ### What changes were proposed in this PR?
    
    Closes #8024. Part of the Form View stack (parent issue #8011), stacked
    on #8440 (PR12).
    
    Shows the chosen results under the workflow that produced them, as a
    **display filter over the canvas's view-result set** (settled design):
    the form reads which operators the canvas already views and shows those,
    and it NEVER writes the canvas's view-result flags, so a normal canvas
    user's result-viewing is unaffected.
    
    - One result card per chosen, currently-viewed step that produced a
    non-empty result: a paginated table (`texera-result-table-frame`), a
    fit-to-card visualisation (`texera-visualization-panel-content`) with
    per-result zoom, or the section shows a compact "press Run" / "no result
    yet" state. A step that produced nothing collapses out rather than
    sitting on a permanent empty card.
    - `shownResultIds` = the author's `resultOperatorIds` kept to only those
    still in `graph.getOperatorsToViewResult()`; it refreshes on every
    result update, so a view-result toggle on the canvas (a co-editor's
    included) is reflected without a reload.
    - Adds `WorkflowResultService.hasNonEmptyResult` (main only had
    `hasAnyResult` = a service exists; this checks the tuple/snapshot count
    so an empty result reads as "no result").
    
    Picking which results to show is an authoring action added by a later PR
    (#8026); opening a step to inspect it read-only is #8025. This PR is
    display-only.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8024. Part of the Form View feature (parent issue #8011).
    
    ### How was this PR tested?
    
    Unit tests (vitest). Direct-construction tests cover the display filter
    (shown = chosen intersect currently-viewed; a de-viewed or deleted
    operator drops out; never writes view-result), the non-empty gating, the
    zoom clamp, `resultKey`/`resultVersion` rebuilding a frame on a new
    result, and the result-update subscription.
    `workflow-result.service.spec.ts` covers `hasNonEmptyResult` (empty vs
    full, paginated vs snapshot). A TestBed test covers the results section
    and card markup (the real table/visualisation children are covered by
    their own specs; their websocket-backed DI does not run in jsdom). 100%
    statement and function coverage on the changed source. `ng test` (146
    tests), `ng build gui`, eslint and prettier all pass.
    
    #### Screenshot
    
    The results section under the workflow: a table and a visualisation card
    after a run.
    <img width="1299" height="492" alt="Screenshot 2026-09-07 at 12 36
    40 PM"
    
src="https://github.com/user-attachments/assets/a05f4e7e-41ec-47b3-97f1-200c74c2b98f";
    />
    
    <!-- paste the screenshot below this line -->
    
    ### 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]>
    Co-authored-by: Meng Wang <[email protected]>
---
 frontend/src/app/common/type/workflow.ts           |   3 +-
 .../workflow-form/workflow-form.component.html     |  70 ++++++
 .../workflow-form/workflow-form.component.scss     | 127 ++++++++++
 .../workflow-form/workflow-form.component.spec.ts  | 229 ++++++++++++++++++
 .../workflow-form/workflow-form.component.ts       | 260 ++++++++++++++++++++-
 .../workflow-form/workflow-form.rendered.spec.ts   |  52 ++++-
 .../workflow-form/workflow-form.spec-harness.ts    |  52 ++++-
 .../workflow-result.service.spec.ts                |  22 ++
 .../workflow-result/workflow-result.service.ts     |  14 ++
 9 files changed, 822 insertions(+), 7 deletions(-)

diff --git a/frontend/src/app/common/type/workflow.ts 
b/frontend/src/app/common/type/workflow.ts
index d0da775756..1aa6b40878 100644
--- a/frontend/src/app/common/type/workflow.ts
+++ b/frontend/src/app/common/type/workflow.ts
@@ -67,7 +67,8 @@ export interface FormBindingConfig {
   };
   /** Array order is display order; the author reorders by dragging. */
   fields: FormFieldBinding[];
-  /** Operators whose results are shown under the workflow after a run. */
+  /** View-result operators whose results are also shown under the workflow 
after a run, on top of
+   *  the final step's result, which always shows. */
   resultOperatorIds: string[];
 }
 
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 d0c0ce0f0e..70007ef891 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
@@ -223,5 +223,75 @@
           class="box"></texera-mini-map>
       </div>
     </section>
+
+    <!-- Results, under the workflow that produced them. The section keeps its 
place before a run so
+         the answer has a visible destination. -->
+    <section class="results">
+      <div class="pc-section-head"><span class="label">Results</span></div>
+      <p
+        class="results-empty"
+        *ngIf="resultIdsToShow.length === 0">
+        {{ isRunning ? "Working…" : hasRunFinished ? "This run produced no 
results to show." : "Press Run and the
+        results appear here." }}
+      </p>
+      <!-- A card only for a step that actually produced a result. Whether a 
Python UDF
+           yields one cannot be told from the graph, so a step earns its card 
at runtime rather than
+           sitting on a permanent "No result yet." (a download/publish step 
never would). -->
+      <ng-container *ngFor="let id of resultIdsToShow">
+        <div class="card result">
+          <div class="result-head">
+            <span>{{ resultLabel(id) }}</span>
+            <!-- Zooming only means something once there is a picture to zoom. 
-->
+            <span
+              class="result-zoom"
+              *ngIf="vizHasContent(id)">
+              <button
+                (click)="zoomResult(id, -1)"
+                [disabled]="resultZoom(id) <= 0"
+                nz-tooltip="Smaller"
+                aria-label="Smaller">
+                <i
+                  nz-icon
+                  nzType="minus"
+                  aria-hidden="true"></i>
+              </button>
+              <button
+                (click)="zoomResult(id, 1)"
+                [disabled]="resultZoom(id) >= 2"
+                nz-tooltip="Bigger"
+                aria-label="Bigger">
+                <i
+                  nz-icon
+                  nzType="plus"
+                  aria-hidden="true"></i>
+              </button>
+            </span>
+          </div>
+          <!-- Three cases, in order: a table (it shows its own "Empty result 
set" when it has no
+               rows), a visualisation that actually drew something, or nothing 
yet -- which
+               collapses to a compact line instead of a tall blank canvas. -->
+          <div
+            class="result-body"
+            [attr.data-zoom]="resultZoom(id)"
+            [ngSwitch]="true">
+            <texera-result-table-frame
+              *ngSwitchCase="isTabularResult(id)"
+              [operatorId]="id"></texera-result-table-frame>
+            <ng-container *ngSwitchCase="vizHasContent(id)">
+              <!-- Keyed on the result's version so a new result builds a new 
frame: the component
+                   reads its content once, at creation. -->
+              <texera-visualization-panel-content
+                *ngFor="let key of [resultKey(id)]; trackBy: trackByKey"
+                [operatorId]="id"></texera-visualization-panel-content>
+            </ng-container>
+            <p
+              *ngSwitchDefault
+              class="result-pending">
+              {{ isRunning ? "Computing…" : "No result yet." }}
+            </p>
+          </div>
+        </div>
+      </ng-container>
+    </section>
   </div>
 </div>
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 93ef44ad83..77185232a6 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
@@ -568,3 +568,130 @@ $shell: #fafafa;
     }
   }
 }
+
+/* ---------- results ---------- */
+
+.results {
+  margin-top: 28px;
+}
+
+/* Quiet on purpose: it marks where the answer will land, and should not 
compete with the inputs
+   the reader is still filling in. */
+.results-empty {
+  margin: 0;
+  padding: 22px 0 6px;
+  color: $text-2;
+  font-size: 13.5px;
+}
+
+.result {
+  margin-bottom: 14px;
+  overflow: hidden;
+
+  .result-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    padding: 9px 13px;
+    border-bottom: 1px solid $divider;
+    font-size: 13px;
+    font-weight: 600;
+    background: $shell;
+  }
+
+  .result-zoom {
+    display: inline-flex;
+    gap: 2px;
+
+    button {
+      border: 1px solid $border;
+      background: #fff;
+      border-radius: 5px;
+      width: 24px;
+      height: 24px;
+      display: grid;
+      place-items: center;
+      color: $text-2;
+      cursor: pointer;
+      font-size: 11px;
+
+      &:hover:not(:disabled) {
+        color: $blue;
+        border-color: $blue;
+      }
+
+      &:disabled {
+        opacity: 0.35;
+        cursor: default;
+      }
+    }
+  }
+
+  /* Centred in the card so an empty / computing step reads at the same height 
as a full one --
+     every card is one fixed height, whatever its state. */
+  .result-pending {
+    margin: 0;
+    height: 100%;
+    display: grid;
+    place-items: center;
+    text-align: center;
+    color: $text-2;
+    font-size: 13.5px;
+  }
+
+  .result-body {
+    padding: 4px;
+    /* Every result card is the SAME height -- table, chart, empty, or 
"computing" -- so the list
+       never jumps between a tall box and a thin strip. Content scrolls 
inside; an empty state
+       centres. Tall enough for a screenful of rows, nowhere near a 
full-window box. */
+    height: 380px;
+    overflow: auto;
+
+    /* The reused result table's empty state ends with "Tip: enable the eye 
icon on the operator
+       ...". There is no such icon on this page and the step is already 
chosen, so that tip only
+       misleads here -- drop it, keep the neutral "Empty result set". */
+    ::ng-deep texera-result-table-frame h4 + p {
+      display: none;
+    }
+
+    /* Centre the table's own "Empty result set" in the card, where the other 
empty states sit.
+       Only the empty block carries this inline style, so a populated table is 
untouched. */
+    ::ng-deep texera-result-table-frame > div[style*="text-align"] {
+      height: 340px;
+      display: grid;
+      place-items: center;
+    }
+
+    /* An iframe has no intrinsic height; fill the card so a chart is the same 
height as a table.
+       The zoom control enlarges the picture inside without changing the card. 
*/
+    texera-visualization-panel-content {
+      display: block;
+      width: 100%;
+      height: 100%;
+    }
+
+    /* Zoom scales the picture itself (the iframe's rendered content), not the 
card frame. The card
+       keeps its height and scrolls to reach the rest of an enlarged image. */
+    &[data-zoom="0"] ::ng-deep iframe {
+      transform: scale(0.7);
+      transform-origin: top left;
+    }
+
+    &[data-zoom="2"] ::ng-deep iframe {
+      transform: scale(1.6);
+      transform-origin: top left;
+    }
+
+    /* A plot with dozens of categories needs the width most: squeezed narrow 
its axis labels
+       collapse into an unreadable smear. Let the iframe and whatever it 
renders take the card. */
+    ::ng-deep iframe,
+    ::ng-deep .visualization-frame,
+    ::ng-deep .plotly-container,
+    ::ng-deep .js-plotly-plot {
+      width: 100% !important;
+      max-width: none !important;
+      height: 100% !important;
+    }
+  }
+}
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 d3ba936e21..8998845095 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
@@ -68,6 +68,7 @@ describe("WorkflowFormComponent", () => {
       h.workflowWebsocketService as any,
       h.host as any,
       h.datePipe as any,
+      h.panelResizeService as any,
       h.validationWorkflowService as any,
       h.config as any
     );
@@ -1055,6 +1056,26 @@ describe("WorkflowFormComponent", () => {
       expect(component.runError).toBe("");
     });
 
+    it("clears a stale failure banner when a new run starts, even a 
co-editor's", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.executionStateStream.next({ current: { state: ExecutionState.Failed, 
errorMessages: [{ message: "boom" }] } });
+      expect(component.runError).not.toBe("");
+
+      // A co-editor starts the next run: the shared stream goes in-flight 
without this page's onRun().
+      h.executionStateStream.next({ current: { state: ExecutionState.Running } 
});
+
+      expect(component.runError).toBe("");
+    });
+
+    it("tells apart a never-run form from a completed run that produced 
nothing", () => {
+      build(formViewWorkflow).ngOnInit();
+      expect(component.hasRunFinished).toBe(false);
+
+      h.executionStateStream.next({ current: { state: ExecutionState.Completed 
} });
+
+      expect(component.hasRunFinished).toBe(true);
+    });
+
     it("stops a running workflow instead of starting another", () => {
       build(formViewWorkflow).ngOnInit();
       h.executionStateStream.next({ current: { state: ExecutionState.Running } 
});
@@ -1095,6 +1116,214 @@ describe("WorkflowFormComponent", () => {
     });
   });
 
+  describe("showing the chosen results", () => {
+    // The terminal always shows (the engine always materializes it); a chosen 
intermediate shows only
+    // while it still has view-result on the canvas. The form never writes the 
view-result set
+    // (display filter, per the settled design).
+    const chosen = (resultOperatorIds: string[]) =>
+      formBindingService.getConfig.mockReturnValue({ instruction: undefined, 
fields: [], resultOperatorIds });
+
+    it("shows a chosen result only while its operator still has view-result on 
the canvas", () => {
+      build(formViewWorkflow).ngOnInit();
+      chosen(["a", "b"]);
+      h.viewResultIds.add("a"); // b's eye is off on the canvas
+
+      (component as any).readConfig();
+
+      expect(component.shownResultIds).toEqual(["a"]);
+    });
+
+    it("shows a chosen terminal operator with no eye", () => {
+      // The engine materializes a terminal operator unconditionally, so its 
result is always available.
+      // The form must show it, or an author who picks the workflow's final 
operator -- the most natural
+      // choice -- would get a card that never appears.
+      build(formViewWorkflow).ngOnInit();
+      chosen(["last"]);
+      h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+      h.terminalIds.add("last"); // no downstream link, and its eye is off
+
+      (component as any).readConfig();
+
+      expect(component.shownResultIds).toEqual(["last"]);
+    });
+
+    it("shows the terminal result with nothing chosen", () => {
+      // A reader who never curates still sees the workflow's final result: 
the engine always
+      // materializes the terminal operator, so its result is always available 
to show.
+      build(formViewWorkflow).ngOnInit();
+      chosen([]);
+      h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+      h.hasOperatorIds.add("last");
+      h.terminalIds.add("last"); // terminal, no eye, not chosen
+
+      (component as any).readConfig();
+
+      expect(component.shownResultIds).toEqual(["last"]);
+    });
+
+    it("does not show a chosen non-terminal operator whose eye is off", () => {
+      // A mid-graph step with an enabled downstream link is materialized only 
when its eye is on;
+      // without the eye it produces no result, so the form must not show a 
card that sits forever empty.
+      build(formViewWorkflow).ngOnInit();
+      chosen(["mid"]);
+      h.graphOperators.push({ operatorID: "mid", operatorType: "Filter" }); // 
enabled downstream, no eye
+
+      (component as any).readConfig();
+
+      expect(component.shownResultIds).toEqual([]);
+    });
+
+    it("treats an operator whose only downstream link is disabled as 
terminal", () => {
+      // The backend's storage rule is out-degree 0 on the ENABLED plan, so an 
operator whose downstream
+      // link is disabled is terminal and gets materialized. The form must 
match, reading enabled links.
+      build(formViewWorkflow).ngOnInit();
+      chosen([]);
+      h.graphOperators.push({ operatorID: "a", operatorType: "Filter" });
+      h.graphOperators.push({ operatorID: "b", operatorType: "Limit" });
+      h.disabledDownstream.add("a"); // a -> b link disabled, so a has no 
enabled downstream
+      h.terminalIds.add("b"); // b is the true end
+
+      (component as any).readConfig();
+
+      expect(component.shownResultIds).toContain("a");
+      expect(component.shownResultIds).toContain("b");
+    });
+
+    it("does not treat a disabled operator as terminal", () => {
+      // A disabled operator is not in the compiled plan, so the engine never 
materializes it; even with
+      // no downstream it must not be shown as a terminal result.
+      build(formViewWorkflow).ngOnInit();
+      chosen([]);
+      h.graphOperators.push({ operatorID: "off", operatorType: "Limit", 
isDisabled: true });
+      h.terminalIds.add("off"); // no downstream, but disabled
+
+      (component as any).readConfig();
+
+      expect(component.shownResultIds).toEqual([]);
+    });
+
+    it("drops a card when the canvas view-result set changes, without a result 
update", () => {
+      build(formViewWorkflow).ngOnInit();
+      chosen(["a", "b"]);
+      h.viewResultIds.add("a");
+      h.viewResultIds.add("b");
+      (component as any).readConfig();
+      expect(component.shownResultIds).toEqual(["a", "b"]);
+
+      // A co-editor turns b's eye off on the canvas. This emits no 
result-update event, so the
+      // filter must react to the view-result set changing directly, or b's 
card would go stale.
+      h.viewResultIds.delete("b");
+      h.viewResultChanged.next({});
+
+      expect(component.shownResultIds).toEqual(["a"]);
+    });
+
+    it("cards only the chosen, viewed steps that actually produced a result", 
() => {
+      build(formViewWorkflow).ngOnInit();
+      chosen(["produces", "produces-nothing"]);
+      h.viewResultIds.add("produces");
+      h.viewResultIds.add("produces-nothing");
+      h.anyResultIds.add("produces"); // the other ran but yielded nothing 
(e.g. a download UDF)
+
+      (component as any).readConfig();
+
+      expect(component.resultIdsToShow).toEqual(["produces"]);
+      expect(component.hasResults).toBe(true);
+    });
+
+    it("has no results when nothing chosen has produced anything", () => {
+      build(formViewWorkflow).ngOnInit();
+      chosen(["a"]);
+      h.viewResultIds.add("a");
+      (component as any).readConfig();
+
+      expect(component.hasResults).toBe(false);
+    });
+
+    it("calls a paginated result a table, and gates visualisation content on a 
snapshot", () => {
+      build(formViewWorkflow).ngOnInit();
+      (component as any).workflowResultService.hasPaginatedResult = (id: 
string) => id === "tab";
+
+      expect(component.isTabularResult("tab")).toBe(true);
+      expect(component.vizHasContent("tab")).toBe(false); // tables take the 
tabular branch
+      // A non-tabular op with a non-empty snapshot has viz content; an empty 
one does not.
+      h.snapshotById.set("viz", [{ a: 1 }]);
+      expect(component.vizHasContent("viz")).toBe(true);
+      expect(component.vizHasContent("blank")).toBe(false);
+    });
+
+    it("labels a result by the operator's friendly name, falling back to the 
id", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.graphOperators.push({ operatorID: "op-1", operatorType: "CSVFileScan" 
});
+
+      expect(component.resultLabel("op-1")).toBe("CSVFileScan");
+      expect(component.resultLabel("gone")).toBe("gone");
+    });
+
+    it("keeps a result's frame identity stable until its version moves", () => 
{
+      build(formViewWorkflow).ngOnInit();
+      const before = component.resultKey("op-1");
+      expect(component.resultKey("op-1")).toBe(before);
+
+      (component as any).resultVersion.set("op-1", 1);
+
+      expect(component.resultKey("op-1")).not.toBe(before);
+      expect(component.trackByKey(0, "k")).toBe("k");
+    });
+
+    it("resizes a result within bounds, per result, and re-fits after", () => {
+      vi.useFakeTimers();
+      build(formViewWorkflow).ngOnInit();
+      const fit = vi.spyOn(component as any, 
"fitVisualisations").mockImplementation(() => {});
+      expect(component.resultZoom("op-1")).toBe(1);
+
+      component.zoomResult("op-1", 1);
+      component.zoomResult("op-1", 1);
+      expect(component.resultZoom("op-1")).toBe(2); // clamped at 2
+
+      component.zoomResult("op-1", -1);
+      component.zoomResult("op-1", -1);
+      component.zoomResult("op-1", -1);
+      expect(component.resultZoom("op-1")).toBe(0); // clamped at 0
+      expect(component.resultZoom("op-2")).toBe(1); // untouched
+
+      // The deferred re-fit runs after the card height lands.
+      vi.advanceTimersByTime(60);
+      expect(fit).toHaveBeenCalled();
+      vi.useRealTimers();
+    });
+
+    it("bumps the result version and re-fits on a result update", () => {
+      vi.useFakeTimers();
+      build(formViewWorkflow).ngOnInit();
+      const fit = vi.spyOn(component as any, 
"fitVisualisations").mockImplementation(() => {});
+
+      h.resultUpdateStream.next({ "op-1": {} });
+      expect(component.resultKey("op-1")).toBe("op-1#1");
+      vi.advanceTimersByTime(300);
+      expect(fit).toHaveBeenCalled();
+      vi.useRealTimers();
+    });
+
+    it("re-fits the charts once a finished run has results", () => {
+      vi.useFakeTimers();
+      build(formViewWorkflow).ngOnInit();
+      vi.spyOn(component, "hasResults", "get").mockReturnValue(true);
+      const fit = vi.spyOn(component as any, 
"fitVisualisations").mockImplementation(() => {});
+
+      h.executionStateStream.next({ current: { state: ExecutionState.Completed 
} });
+      vi.advanceTimersByTime(400);
+
+      expect(fit).toHaveBeenCalled();
+      vi.useRealTimers();
+    });
+
+    it("gives the result tables a realistic page height on init", () => {
+      build(formViewWorkflow).ngOnInit();
+      expect(h.panelResizeService.changePanelSize).toHaveBeenCalled();
+    });
+  });
+
   describe("reporting a failed run", () => {
     it("blames empty required inputs when a required field is left empty", () 
=> {
       build(formViewWorkflow).ngOnInit();
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 f1070a7ccf..6bed647f53 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
@@ -26,6 +26,8 @@ import { ActivatedRoute, Router } from "@angular/router";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { NzAvatarModule } from "ng-zorro-antd/avatar";
 import { NzIconModule } from "ng-zorro-antd/icon";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { NzTooltipModule } from "ng-zorro-antd/tooltip";
 import { UserIconComponent } from 
"../../../dashboard/component/user/user-icon/user-icon.component";
 import { cloneDeep } from "lodash-es";
 import { MarkdownService } from "ngx-markdown";
@@ -51,10 +53,13 @@ import { ValidationWorkflowService } from 
"../../service/validation/validation-w
 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 { PanelResizeService } from 
"../../service/workflow-result/panel-resize/panel-resize.service";
 import { WorkflowWebsocketService } from 
"../../service/workflow-websocket/workflow-websocket.service";
 import { ExecutionState } from "../../types/execute-workflow.interface";
 import { Point } from "../../types/workflow-common.interface";
 import { ComputingUnitSelectionComponent } from 
"../power-button/computing-unit-selection.component";
+import { ResultTableFrameComponent } from 
"../result-panel/result-table-frame/result-table-frame.component";
+import { VisualizationFrameContentComponent } from 
"../visualization-panel-content/visualization-frame-content.component";
 import { WorkflowEditorComponent } from 
"../workflow-editor/workflow-editor.component";
 import { MiniMapComponent } from 
"../workflow-editor/mini-map/mini-map.component";
 import { CoeditorUserIconComponent } from 
"../menu/coeditor-user-icon/coeditor-user-icon.component";
@@ -81,8 +86,12 @@ interface RenderedField {
  * makes, with each sub-field of a nested or repeated property renamed and 
hidden as the author set
  * it up. It also shows the author's instruction above the inputs, and runs 
the workflow: a Run
  * button (a reader's simplified Run/Stop, sharing the canvas's disable 
conditions), the
- * computing-unit selector, a run clock and plain-language failure messages. 
Showing the results is
- * added by a later PR. A view, not a new object: it opens the same workflow 
the canvas does.
+ * computing-unit selector, a run clock and plain-language failure messages. 
It then shows results
+ * underneath -- the final step's output plus the author's chosen view-result 
steps, each a table, a
+ * visualisation, or a compact "no result yet" -- reading the canvas's 
view-result set and never
+ * writing it. Opening a step to
+ * inspect it read-only, and the authoring mode that picks what to show, are 
later PRs. A view, not
+ * a new object: it opens the same workflow the canvas does.
  */
 @UntilDestroy()
 @Component({
@@ -96,8 +105,12 @@ interface RenderedField {
     FormlyModule,
     NzAvatarModule,
     NzIconModule,
+    NzButtonModule,
+    NzTooltipModule,
     UserIconComponent,
     ComputingUnitSelectionComponent,
+    ResultTableFrameComponent,
+    VisualizationFrameContentComponent,
     WorkflowEditorComponent,
     MiniMapComponent,
     CoeditorUserIconComponent,
@@ -143,6 +156,20 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
   public isWorkflowValid = true;
   public isWorkflowEmpty = false;
 
+  /**
+   * Which steps' results to show: the terminal (final) steps, whose results 
the engine always
+   * materializes, plus the author's chosen `resultOperatorIds` kept to those 
that still have
+   * view-result ("the eye") on the canvas. The form NEVER writes the canvas's 
view-result flags --
+   * it only decides what it itself shows, so a canvas user's result-viewing 
is unaffected.
+   */
+  public shownResultIds: string[] = [];
+  /** Chart height per result (0 compact / 1 default / 2 tall). Per operator 
so one does not resize
+   *  the others, and in memory only -- a viewing preference, not part of the 
workflow. */
+  private zoomByResult = new Map<string, number>();
+  /** Bumped when a result changes, used as the chart's *ngFor identity so the 
frame is rebuilt, not
+   *  reused: the chart reads its content once at creation, so a stale frame 
showed "undefined". */
+  private resultVersion = new Map<string, number>();
+
   /** The collapsible workflow preview: closed until the reader opens it. */
   public workflowOpen = false;
   /** The embedded canvas is built the first time the strip opens, never while 
collapsed. */
@@ -189,6 +216,10 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     private workflowWebsocketService: WorkflowWebsocketService,
     private host: ElementRef<HTMLElement>,
     private datePipe: DatePipe,
+    // The result table sizes its rows-per-page from this shared panel height. 
On the operator
+    // canvas the docked panel drives it; this page has no such panel, so left 
at the tiny default
+    // every table showed a single row per page. Given a realistic height in 
ngOnInit instead.
+    private panelResizeService: PanelResizeService,
     // Same source the operator canvas reads its "Invalid" / "Empty" states 
from, so Run is
     // disabled here exactly when it is disabled there.
     private validationWorkflowService: ValidationWorkflowService,
@@ -202,8 +233,39 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
       return;
     }
     this.wid = wid;
+    // Give the result tables a realistic height to page against, so they show 
a screenful of rows
+    // instead of one. (~7 rows; the card scrolls for the rest.)
+    this.panelResizeService.changePanelSize(900, 560);
     this.load(wid);
 
+    // A result changing bumps that operator's version (so its chart frame is 
rebuilt, not reused),
+    // re-limits what the form shows to the currently-viewed set, and re-fits 
the visualisations.
+    this.workflowResultService
+      .getResultUpdateStream()
+      .pipe(untilDestroyed(this))
+      .subscribe(update => {
+        for (const operatorID of Object.keys(update ?? {})) {
+          this.resultVersion.set(operatorID, 
(this.resultVersion.get(operatorID) ?? 0) + 1);
+        }
+        this.refreshShownResults();
+        // markForCheck, not detectChanges: this fires often during a run, and 
a synchronous pass
+        // can be thrown out of by an unrelated component's NG0100, killing 
the subscription.
+        this.cdr.markForCheck();
+        this.later(() => this.fitVisualisations(), 300);
+      });
+
+    // Turning a step's view-result OFF on the canvas emits no result-update 
event, so the filter
+    // above would miss it and leave a stale card. React to the view-result 
set changing directly
+    // (a co-editor's toggle included), so a de-viewed step drops out here at 
once.
+    this.workflowActionService
+      .getTexeraGraph()
+      .getViewResultOperatorsChangedStream()
+      .pipe(untilDestroyed(this))
+      .subscribe(() => {
+        this.refreshShownResults();
+        this.cdr.markForCheck();
+      });
+
     // The run clock, reusing the operator canvas's source outright rather 
than timing anything
     // here: the engine is the only thing that knows when the run really 
began, so a stopwatch
     // started at the click would drift and would be wrong after a reload.
@@ -255,10 +317,17 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
       .getExecutionStateStream()
       .pipe(untilDestroyed(this))
       .subscribe(({ current }) => {
+        const wasRunning = this.isRunning;
         this.executionState = current.state;
+        // Clear a stale failure banner the moment any new run starts -- this 
session's or a
+        // co-editor's. onRun() clears it for a run started here, but a 
co-editor's run moves the
+        // shared execution stream to an in-flight state without going through 
onRun(), so without
+        // this the previous failure would linger over their running run.
+        if (!wasRunning && this.isRunning) {
+          this.runError = "";
+        }
         // Surface a failed run. Without this the spinner just stops and the 
form gives zero
-        // feedback -- the opposite of what a reader needs. onRun() clears 
runError before the next
-        // run, so a stale error never lingers.
+        // feedback -- the opposite of what a reader needs.
         if (current.state === ExecutionState.Failed) {
           // A required input left empty is by far the commonest reason a run 
fails here, and the
           // engine reports it as an opaque "... is not contained in the 
schema". Answer with the
@@ -268,6 +337,12 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
             ? "Run failed: please fill in the required fields."
             : 
this.friendlyRunError(current.errorMessages?.[0]?.message?.trim() ?? "");
         }
+        // Fit the charts to their cards once a run has results. Deliberately 
not on run START: the
+        // run repaints operators and a re-fit then zoomed the whole preview 
down. Deliberately does
+        // not open the workflow either -- someone using the form came for the 
inputs and results.
+        if (this.hasResults) {
+          this.later(() => this.fitVisualisations(), 400);
+        }
         // markForCheck, not detectChanges: this is the one subscription the 
page cannot afford to
         // lose. A synchronous detectChanges can be thrown out of by an 
unrelated component's NG0100,
         // which would kill this stream and freeze the Run button on a stale 
state with no error
@@ -376,11 +451,48 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     this.parameters = this.formBindingService.resolveFields();
     this.instructionTitle = config.instruction?.title ?? "";
     this.instructionBody = config.instruction?.body ?? "";
+    this.refreshShownResults();
     // A reader always sees the instruction as rendered markdown.
     void this.renderInstruction();
     this.buildForm();
   }
 
+  /**
+   * Decide which operators' result cards to show. The engine materializes a 
result for every terminal
+   * operator (no enabled downstream) as well as every view-result operator, 
so a terminal's result is
+   * always available; the form shows terminals by default and layers the 
author's chosen view-result
+   * steps on top. This is a pure display filter that reads the graph and 
never writes it, so a normal
+   * canvas user's result-viewing is unaffected. A step that is neither viewed 
nor terminal (or was
+   * deleted) drops out rather than rendering a stale card.
+   */
+  private refreshShownResults(): void {
+    const graph = this.workflowActionService.getTexeraGraph();
+    const viewed = graph.getOperatorsToViewResult();
+    // A result exists for an operator that is view-result (the eye) or 
terminal (no enabled
+    // downstream); the engine materializes both (WorkflowCompiler stores 
terminal operators plus
+    // opsToViewResult). A view-result id is always a live operator; a 
terminal id comes from
+    // terminalOperatorIds (live, enabled operators only), so both branches 
reference real steps.
+    const terminals = new Set(this.terminalOperatorIds());
+    const availableOnCanvas = (id: string): boolean => viewed.has(id) || 
terminals.has(id);
+    const chosen = this.formBindingService.getConfig().resultOperatorIds;
+    // The terminal (final) operator's result always shows -- the engine 
always materializes it, so it
+    // cannot be turned off. resultOperatorIds adds extra intermediate 
(view-result) steps on top. The
+    // downstream hasNonEmptyResult filter drops steps that produced no data.
+    this.shownResultIds = [...new Set([...terminals, 
...chosen])].filter(availableOnCanvas);
+  }
+
+  /** The workflow's terminal operators: enabled operators with no enabled 
downstream link. Matches the
+   *  backend's storage rule (WorkflowCompiler treats out-degree-0 operators 
of the enabled plan as
+   *  terminal and always materializes them), so a disabled link or operator 
does not mislead this. */
+  private terminalOperatorIds(): string[] {
+    const graph = this.workflowActionService.getTexeraGraph();
+    const hasEnabledDownstream = new Set(graph.getAllEnabledLinks().map(link 
=> link.source.operatorID));
+    return graph
+      .getAllOperators()
+      .filter(op => !(op.isDisabled ?? false) && 
!hasEnabledDownstream.has(op.operatorID))
+      .map(op => op.operatorID);
+  }
+
   /**
    * Build the form from the operators' JSON schemas (FormlyJsonschema), 
keeping the one field per
    * exposed property. Each input gets its own form keyed by binding id.
@@ -627,6 +739,132 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     return rendered.resolved.binding.id;
   }
 
+  // 
---------------------------------------------------------------------------
+  // Results: the final step's output plus the chosen steps', shown under the 
workflow that produced it
+  // 
---------------------------------------------------------------------------
+
+  public get hasResults(): boolean {
+    return this.shownResultIds.some(id => 
this.workflowResultService.hasNonEmptyResult(id));
+  }
+
+  /**
+   * The shown steps (terminal plus chosen) that actually produced a result, 
so only those get a
+   * card. Whether a Python UDF yields a result cannot be known from the graph 
-- some (a
+   * download/publish step) never do -- so a step earns its card at runtime 
rather than sitting on a
+   * permanent "No result yet.".
+   */
+  public get resultIdsToShow(): string[] {
+    return this.shownResultIds.filter(id => 
this.workflowResultService.hasNonEmptyResult(id));
+  }
+
+  public isTabularResult(operatorID: string): boolean {
+    return this.workflowResultService.hasPaginatedResult(operatorID);
+  }
+
+  /**
+   * Whether this step's visualisation drew something. A visualiser reserves a 
fixed canvas even
+   * when empty, so gating on real content lets an empty result collapse to 
the compact "No result
+   * yet" line instead of a tall blank box. Tables are excluded (they take the 
tabular branch).
+   */
+  public vizHasContent(operatorID: string): boolean {
+    if (this.isTabularResult(operatorID)) {
+      return false;
+    }
+    const snapshot = 
this.workflowResultService.getResultService(operatorID)?.getCurrentResultSnapshot();
+    return !!snapshot && snapshot.length > 0;
+  }
+
+  /** The operator's friendly label for a result card, falling back to its raw 
id. */
+  public resultLabel(operatorID: string): string {
+    const operator = 
this.workflowActionService.getTexeraGraph().getOperator(operatorID);
+    return operator ? this.formBindingService.operatorLabel(operator) : 
operatorID;
+  }
+
+  public trackByKey(_: number, key: string): string {
+    return key;
+  }
+
+  /**
+   * A per-result identity that changes on each result-update for that 
operator, used as the chart's
+   * *ngFor key so the frame is rebuilt (not reused) when the result changes: 
the chart reads its
+   * content once at creation, so a reused frame kept showing the old (or 
"undefined") picture. A
+   * visualisation's result arrives as a single snapshot, so in practice this 
bumps about once per
+   * result rather than per tuple.
+   */
+  public resultKey(operatorID: string): string {
+    return operatorID + "#" + (this.resultVersion.get(operatorID) ?? 0);
+  }
+
+  public resultZoom(operatorID: string): number {
+    return this.zoomByResult.get(operatorID) ?? 1;
+  }
+
+  public zoomResult(operatorID: string, delta: number): void {
+    const next = Math.min(2, Math.max(0, this.resultZoom(operatorID) + delta));
+    this.zoomByResult.set(operatorID, next);
+    // Let the new card height land, then have the chart redraw into it -- 
growing the frame alone
+    // leaves the picture at its old size until something asks it to 
re-measure.
+    this.cdr.detectChanges();
+    this.later(() => this.fitVisualisations(), 60);
+  }
+
+  /**
+   * Scale each visualisation to its card. They render in a same-origin srcdoc 
iframe at natural
+   * size, so we inject a stylesheet to fit the content to the card and fire a 
resize so chart
+   * libraries re-lay out. The operator's output is untouched.
+   */
+  /* v8 ignore start -- iframe/Plotly DOM fitting; no coverage in jsdom */
+  private fitVisualisations(): void {
+    const frames = 
this.host.nativeElement.querySelectorAll<HTMLIFrameElement>(".result-body 
iframe");
+    frames.forEach(frame => {
+      const apply = () => {
+        try {
+          const doc = frame.contentDocument;
+          if (!doc?.body) {
+            return;
+          }
+          if (!doc.getElementById("pc-fit")) {
+            const style = doc.createElement("style");
+            style.id = "pc-fit";
+            style.textContent = `
+              html, body { margin: 0; padding: 8px; overflow-x: hidden; }
+              .js-plotly-plot, .plot-container, .plotly, .svg-container { 
width: 100% !important; height: 100% !important; }
+              img, svg, canvas, video { max-width: 100% !important; height: 
auto !important; }
+              table { max-width: 100%; }
+            `;
+            doc.head?.appendChild(style);
+          }
+          const win = frame.contentWindow as (Window & { Plotly?: any }) | 
null;
+          const plots = doc.querySelectorAll<HTMLElement>(".js-plotly-plot");
+          if (win?.Plotly?.Plots?.resize && plots.length) {
+            plots.forEach(plot => {
+              plot.style.width = "100%";
+              plot.style.height = "100%";
+              try {
+                win.Plotly.Plots.resize(plot);
+              } catch {
+                // A chart mid-render cannot be resized; the next call will 
catch it.
+              }
+            });
+          }
+          win?.dispatchEvent(new Event("resize"));
+        } catch {
+          // A cross-origin document cannot be styled from here; leave it as 
it came.
+        }
+      };
+      apply();
+      // Re-apply after the iframe (re)loads. Bind once per frame element 
(guarded by a data flag):
+      // a `{ once: true }` listener added on every fit call never fires for 
an already-loaded frame,
+      // so repeated zoom/fit calls would pile up detached listeners. A single 
persistent listener
+      // per frame re-fits on each reload and is torn down with the frame.
+      if (!frame.dataset.pcFitBound) {
+        frame.dataset.pcFitBound = "1";
+        frame.addEventListener("load", apply);
+      }
+    });
+  }
+  /* v8 ignore stop */
+
   // 
---------------------------------------------------------------------------
   // Instruction: the author's one piece of guidance, shown as rendered 
markdown
   // 
---------------------------------------------------------------------------
@@ -668,6 +906,20 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     );
   }
 
+  /**
+   * A run has started and ended, as opposed to never having run. Lets the 
empty results section say
+   * "this run produced nothing" after a completed-but-empty run, instead of 
the "press Run" hint
+   * that wrongly implies nothing has run yet.
+   */
+  public get hasRunFinished(): boolean {
+    return (
+      this.executionState === ExecutionState.Completed ||
+      this.executionState === ExecutionState.Failed ||
+      this.executionState === ExecutionState.Killed ||
+      this.executionState === ExecutionState.Terminated
+    );
+  }
+
   /**
    * A unit is picked but its socket is still coming up -- the same window the 
operator canvas shows
    * "Connecting" and disables its run button. Read from the exact condition 
the canvas uses
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 bca2ee4e04..40f28d3f82 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
@@ -33,6 +33,8 @@ import {
   WarningOutline,
   LoadingOutline,
   LockOutline,
+  MinusOutline,
+  PlusOutline,
 } from "@ant-design/icons-angular/icons";
 import { EMPTY, of, Subject } from "rxjs";
 
@@ -56,6 +58,9 @@ import { WorkflowConsoleService } from 
"../../service/workflow-console/workflow-
 import { WorkflowWebsocketService } from 
"../../service/workflow-websocket/workflow-websocket.service";
 import { ValidationWorkflowService } from 
"../../service/validation/validation-workflow.service";
 import { ComputingUnitSelectionComponent } from 
"../power-button/computing-unit-selection.component";
+import { ResultTableFrameComponent } from 
"../result-panel/result-table-frame/result-table-frame.component";
+import { VisualizationFrameContentComponent } from 
"../visualization-panel-content/visualization-frame-content.component";
+import { PanelResizeService } from 
"../../service/workflow-result/panel-resize/panel-resize.service";
 import { WorkflowComputingUnitManagingService } from 
"../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
 import { WorkflowExecutionsService } from 
"../../../dashboard/service/user/workflow-executions/workflow-executions.service";
 import { ComputingUnitActionsService } from 
"../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service";
@@ -97,6 +102,11 @@ describe("WorkflowFormComponent (rendered template)", () => 
{
     // than overriding the page's imports, which would JIT-recompile the page 
and drop its
     // host-binding coverage -- keeps the run bar around it rendered and the 
page fully covered.
     TestBed.overrideComponent(ComputingUnitSelectionComponent, { set: { 
template: "" } });
+    // Blank the two result frame children: the real table/visualization need 
a live result service
+    // and (for the chart) an iframe jsdom cannot run. Blanking the children 
keeps the page's own
+    // results markup -- the section, the card, the head, the zoom controls -- 
rendered and covered.
+    TestBed.overrideComponent(ResultTableFrameComponent, { set: { template: "" 
} });
+    TestBed.overrideComponent(VisualizationFrameContentComponent, { set: { 
template: "" } });
     /* eslint-enable no-restricted-syntax */
 
     await TestBed.configureTestingModule({
@@ -130,6 +140,10 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
               triggerCenterEvent: vi.fn(),
               hasOperator: () => false,
               getOperator: () => undefined,
+              getAllOperators: () => [],
+              getAllEnabledLinks: () => [],
+              getOperatorsToViewResult: () => new Set<string>(),
+              getViewResultOperatorsChangedStream: () => EMPTY,
             }),
           },
         },
@@ -171,7 +185,17 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
             resetExecutionAndWorkers: vi.fn(),
           },
         },
-        { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } 
},
+        {
+          provide: WorkflowResultService,
+          useValue: {
+            clearResults: vi.fn(),
+            getResultUpdateStream: () => EMPTY,
+            hasNonEmptyResult: () => false,
+            hasPaginatedResult: () => false,
+            getResultService: () => undefined,
+          },
+        },
+        { provide: PanelResizeService, useValue: { changePanelSize: vi.fn() } 
},
         { provide: NotificationService, useValue: { error: vi.fn() } },
         { provide: UserService, useValue: { getCurrentUser: () => undefined, 
isLogin: () => false } },
         { provide: MarkdownService, useValue: { parse: (s: string) => s } },
@@ -212,6 +236,8 @@ describe("WorkflowFormComponent (rendered template)", () => 
{
             WarningOutline,
             LoadingOutline,
             LockOutline,
+            MinusOutline,
+            PlusOutline,
           ],
         },
         DatePipe,
@@ -391,6 +417,30 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
     expect(el(".run-note")?.getAttribute("role")).toBe("status");
   });
 
+  it("renders the results section: empty state, then a result card for a 
produced step", () => {
+    fixture.detectChanges();
+    finishLoad();
+    const c = fixture.componentInstance;
+
+    // Before any result: the section shows its quiet empty line, no cards.
+    expect(el(".results .label")?.textContent?.trim()).toBe("Results");
+    expect(el(".results-empty")).not.toBeNull();
+    expect(el(".result")).toBeNull();
+
+    // A chosen step reports a non-empty result: a card appears. Kept in the 
neutral "no result
+    // yet" switch branch (not tabular, no snapshot) so the heavy 
table/visualization children --
+    // which their own specs cover, and which drag in a websocket/status chain 
jsdom cannot run --
+    // are not instantiated here; this test covers the page's own card + head 
markup.
+    const wrs: any = TestBed.inject(WorkflowResultService);
+    wrs.hasNonEmptyResult = () => true;
+    c.shownResultIds = ["op-1"];
+    fixture.detectChanges();
+
+    expect(el(".results-empty")).toBeNull();
+    expect(el(".result .result-head")).not.toBeNull();
+    expect(el(".result .result-body")).not.toBeNull();
+  });
+
   it("tears the workflow down when the browser unloads (the beforeunload host 
binding)", () => {
     fixture.detectChanges();
     finishLoad();
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 4a9b7fa605..96c45de05b 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
@@ -65,10 +65,25 @@ export function setupHarness() {
   const selectedUnitStream = new Subject<any>();
   const validationStream = new Subject<{ errors: Record<string, unknown>; 
workflowEmpty: boolean }>();
   const connectionStream = new Subject<boolean>();
+  // A result changed; the form bumps versions and re-fits. Tests drive it 
directly.
+  const resultUpdateStream = new Subject<Record<string, unknown>>();
+  // Fires when the canvas's view-result set changes (a co-editor's eye toggle 
included).
+  const viewResultChanged = new Subject<unknown>();
   // The operators the graph holds: `hasOperatorIds` gates operatorSchemaFor, 
`graphOperators`
   // supplies each operator's type (which picks the custom widget). Tests add 
to them as needed.
   const hasOperatorIds = new Set<string>();
   const graphOperators: any[] = [];
+  // Operators with view-result ("the eye") on in the canvas; the form shows 
these on top of the
+  // always-shown terminal steps.
+  const viewResultIds = new Set<string>();
+  // Operators that produced a non-empty result -- drives hasNonEmptyResult in 
the result mock.
+  const anyResultIds = new Set<string>();
+  // Operators the engine treats as terminal (out-degree 0 on the ENABLED 
plan): the engine materializes
+  // a terminal's result with no eye set, so the form shows it too. A test 
marks an id terminal by adding
+  // it here (no downstream at all) or to disabledDownstream (its only 
downstream link is disabled) --
+  // both mean "no enabled downstream link", so getAllEnabledLinks emits no 
link from that operator.
+  const terminalIds = new Set<string>();
+  const disabledDownstream = new Set<string>();
   // The preview centres the embedded graph once it is built; tests assert 
this fired.
   const triggerCenterEvent = vi.fn();
 
@@ -89,6 +104,20 @@ export function setupHarness() {
       triggerCenterEvent,
       hasOperator: (id: string) => hasOperatorIds.has(id),
       getOperator: (id: string) => graphOperators.find(o => o.operatorID === 
id),
+      getAllOperators: () => graphOperators,
+      getOperatorsToViewResult: () => new Set(viewResultIds),
+      getViewResultOperatorsChangedStream: () => 
viewResultChanged.asObservable(),
+      // Enabled links only: a non-terminal operator emits one dummy outgoing 
link; a terminal operator
+      // (in terminalIds, or whose downstream is disabled via 
disabledDownstream) emits none. Drives the
+      // component's terminal detection (terminalOperatorIds).
+      getAllEnabledLinks: () =>
+        graphOperators
+          .filter(op => !terminalIds.has(op.operatorID) && 
!disabledDownstream.has(op.operatorID))
+          .map(op => ({
+            linkID: `${op.operatorID}-out`,
+            source: { operatorID: op.operatorID },
+            target: { operatorID: "downstream" },
+          })),
     }),
     // Exposing or un-exposing a property announces on this stream; the form 
re-reads its config.
     formBindingChanged$: new Subject<unknown>(),
@@ -102,6 +131,8 @@ export function setupHarness() {
     resolveFields: vi.fn().mockReturnValue([]),
     readValue: vi.fn().mockReturnValue(undefined),
     writeValue: vi.fn(),
+    // A result card's friendly label; the mock returns the operator's display 
name or its id.
+    operatorLabel: (op: any) => op?.customDisplayName ?? op?.operatorType ?? 
op?.operatorID,
   };
   // A field per property the tests expose. Real formly json-schema conversion 
is exercised by the
   // property panel's own spec; here a deterministic map keeps these tests 
about the component's
@@ -178,7 +209,18 @@ export function setupHarness() {
     killWorkflow: vi.fn(),
     resetExecutionAndWorkers: vi.fn(),
   };
-  const workflowResultService = { clearResults: vi.fn() };
+  // Results: `anyResultIds` marks which operators produced a non-empty 
result; `snapshotById`
+  // lets a test give an operator a snapshot (drives vizHasContent). 
`hasPaginatedResult` is off
+  // unless a test overrides it. `getResultUpdateStream` is the stream the 
form watches.
+  const snapshotById = new Map<string, ReadonlyArray<object>>();
+  const workflowResultService = {
+    clearResults: vi.fn(),
+    getResultUpdateStream: () => resultUpdateStream.asObservable(),
+    hasNonEmptyResult: (id: string) => anyResultIds.has(id),
+    hasPaginatedResult: (_id: string) => false,
+    getResultService: (id: string) => ({ getCurrentResultSnapshot: () => 
snapshotById.get(id) }),
+  };
+  const panelResizeService = { changePanelSize: vi.fn() };
   const notificationService = { error: vi.fn() };
   // 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) };
@@ -235,6 +277,7 @@ export function setupHarness() {
     computingUnitStatusService,
     workflowConsoleService,
     workflowWebsocketService,
+    panelResizeService,
     validationWorkflowService,
     host,
     datePipe,
@@ -248,8 +291,15 @@ export function setupHarness() {
     selectedUnitStream,
     validationStream,
     connectionStream,
+    resultUpdateStream,
+    viewResultChanged,
     hasOperatorIds,
     graphOperators,
+    viewResultIds,
+    anyResultIds,
+    terminalIds,
+    disabledDownstream,
+    snapshotById,
     triggerCenterEvent,
   };
 }
diff --git 
a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts
 
b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts
index 31b83b5b10..e53de2c11f 100644
--- 
a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts
+++ 
b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts
@@ -115,6 +115,28 @@ describe("WorkflowResultService", () => {
     expect(updateEvents).toEqual([updates]);
   });
 
+  it("hasNonEmptyResult tells an empty result apart from one with rows", () => 
{
+    const ws = TestBed.inject(WorkflowWebsocketService);
+    pushWsEvent(ws, {
+      type: "WebResultUpdateEvent",
+      updates: {
+        emptyPag: paginationUpdate(0),
+        fullPag: paginationUpdate(5),
+        emptySnap: snapshotUpdate([]),
+        fullSnap: snapshotUpdate([{ a: 1 }]),
+      },
+      tableStats: {},
+    });
+
+    // A service exists for the empty step (hasAnyResult), but it holds 
nothing.
+    expect(service.hasAnyResult("emptyPag")).toBe(true);
+    expect(service.hasNonEmptyResult("emptyPag")).toBe(false);
+    expect(service.hasNonEmptyResult("fullPag")).toBe(true);
+    expect(service.hasNonEmptyResult("emptySnap")).toBe(false);
+    expect(service.hasNonEmptyResult("fullSnap")).toBe(true);
+    expect(service.hasNonEmptyResult("never-ran")).toBe(false);
+  });
+
   it("announces newly-created operators on the result-initiate stream", () => {
     const ws = TestBed.inject(WorkflowWebsocketService);
     const initiated: string[] = [];
diff --git 
a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts 
b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts
index 89d4e453fa..e7879ba15e 100644
--- 
a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts
+++ 
b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts
@@ -64,6 +64,20 @@ export class WorkflowResultService {
     return this.hasResult(operatorID) || this.hasPaginatedResult(operatorID);
   }
 
+  /**
+   * Whether the operator produced an actual, non-empty result. A step marked 
view-result still
+   * registers a (paginated) result service with zero tuples -- e.g. a UDF 
that only writes a file
+   * or logs -- so hasAnyResult alone is true for those. This checks the tuple 
count / snapshot
+   * length so an empty result reads as "no result".
+   */
+  public hasNonEmptyResult(operatorID: string): boolean {
+    const paginated = this.getPaginatedResultService(operatorID);
+    if (paginated) {
+      return paginated.getCurrentTotalNumTuples() > 0;
+    }
+    return 
(this.getResultService(operatorID)?.getCurrentResultSnapshot()?.length ?? 0) > 
0;
+  }
+
   public hasResult(operatorID: string): boolean {
     return isDefined(this.getResultService(operatorID));
   }

Reply via email to