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

commit 6d0931cb9b193d87f477cca039c9c805126f452d
Author: Suryaa Charan <[email protected]>
AuthorDate: Mon Sep 28 21:32:34 2026 +0000

    feat(frontend): filter console messages by type (#8515)
    
    ### What changes were proposed in this PR?
    
    The console badges every message with its type but offers no way to act
    on it. A Python UDF that prints per tuple buries a single ERROR under
    its output, and the panel auto-scrolls, pushing it out of view.
    
    Adds a filter menu beside the settings gear: a checkbox per message type
    plus a select-all, and a line above the list reporting how many messages
    are hidden. The gear keeps the display options it already had.
    
    Each filter option carries the same badge colour as the rows it
    controls. Every type is visible by default, and a type the console has
    not seen before stays visible rather than being hidden by an
    unknown-type default.
    
    Both toolbar controls become buttons so the menus can be reached by
    keyboard — the settings gear could not be, as a bare `<a>` with no
    `href`. Verified by focusing the trigger and pressing Enter.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8495
    
    ### How was this PR tested?
    
    42 unit tests in `console-frame.component.spec.ts`, up from 33 before
    this change.
    
    Verified live against a `1-out Python UDF` emitting 23 PRINT messages,
    one ERROR, plus COMMAND and DEBUGGER from the debug console — unticking
    Print leaves the error alone at the top.
    
    The DOM tests were checked by mutation: swapping two checkbox bindings,
    and replacing the hidden-count interpolation with a literal, each fail a
    test.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5)
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    https://claude.ai/code/session_016m8VBHFdnbBvNovMonAK9t
    
    ---------
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../console-frame/console-frame.component.html     |  90 ++++-
 .../console-frame/console-frame.component.scss     |  49 +++
 .../console-frame/console-frame.component.spec.ts  | 367 ++++++++++++++++++++-
 .../console-frame/console-frame.component.ts       |  82 ++++-
 4 files changed, 562 insertions(+), 26 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.html
 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.html
index 74f8f9dc34..7e684d0373 100644
--- 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.html
+++ 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.html
@@ -17,15 +17,54 @@
  under the License.
 -->
 
-<a
-  nz-dropdown
-  [nzDropdownMenu]="menu">
-  <span
-    nz-icon
-    nzType="setting"
-    nzTheme="outline"></span>
-</a>
-<nz-dropdown-menu #menu="nzDropdownMenu">
+<!-- Buttons rather than anchors so the menus can be reached by keyboard.
+     Click-triggered with [nzClickHide]="false": each menu holds several
+     independent options, so the defaults (open on hover, dismiss on any click)
+     would mean reopening it once per option. -->
+<div class="console-toolbar">
+  <button
+    nz-button
+    nzType="text"
+    nzSize="small"
+    nz-dropdown
+    nzTrigger="click"
+    [nzClickHide]="false"
+    nzOverlayClassName="console-display-settings"
+    [nzDropdownMenu]="settingsMenu"
+    [(nzVisible)]="settingsMenuOpen"
+    nz-tooltip
+    nzTooltipTitle="Display options"
+    aria-label="Display options"
+    aria-haspopup="true"
+    [attr.aria-expanded]="settingsMenuOpen">
+    <span
+      nz-icon
+      nzType="setting"
+      nzTheme="outline"></span>
+  </button>
+  <button
+    nz-button
+    nzType="text"
+    nzSize="small"
+    nz-dropdown
+    nzTrigger="click"
+    [nzClickHide]="false"
+    nzOverlayClassName="console-type-filter"
+    [nzDropdownMenu]="typeFilterMenu"
+    [(nzVisible)]="typeFilterMenuOpen"
+    nz-tooltip
+    nzTooltipTitle="Filter by message type"
+    aria-label="Filter by message type"
+    aria-haspopup="true"
+    [attr.aria-expanded]="typeFilterMenuOpen">
+    <span
+      nz-icon
+      nzType="filter"
+      nzTheme="outline"
+      class="filter-icon"></span>
+  </button>
+</div>
+<nz-dropdown-menu #settingsMenu="nzDropdownMenu">
   <ul nz-menu>
     <li nz-menu-item>
       <nz-switch
@@ -53,13 +92,44 @@
     </li>
   </ul>
 </nz-dropdown-menu>
+<nz-dropdown-menu #typeFilterMenu="nzDropdownMenu">
+  <ul nz-menu>
+    <li nz-menu-item>
+      <label
+        nz-checkbox
+        [nzChecked]="allTypesVisible"
+        [nzIndeterminate]="typeFilterIndeterminate"
+        (nzCheckedChange)="setAllTypesVisible($event)"
+        >Select all</label
+      >
+    </li>
+    <li nz-menu-divider></li>
+    <li
+      nz-menu-item
+      *ngFor="let type of messageTypes">
+      <label
+        nz-checkbox
+        [nzChecked]="isTypeVisible(type)"
+        (nzCheckedChange)="setTypeVisibility(type, $event)">
+        <nz-badge [nzStatus]="labelMapping.get(type) ?? ''"></nz-badge>
+        {{ typeLabel(type) }}
+      </label>
+    </li>
+  </ul>
+</nz-dropdown-menu>
+
+<div
+  *ngIf="hiddenMessageCount > 0"
+  class="hidden-count-notice">
+  Hidden by filter: {{ hiddenMessageCount }}
+</div>
 
 <nz-list
   nzSize="small"
   class="console-list-container"
   #consoleList>
   <nz-list-item
-    *ngFor="let entry of consoleMessages"
+    *ngFor="let entry of filteredMessages"
     class="console-message-entry"
     nzNoFlex>
     <div
diff --git 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.scss
 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.scss
index 5fb7a61945..358c7bf8ed 100644
--- 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.scss
+++ 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.scss
@@ -37,6 +37,55 @@
   color: red;
 }
 
+.console-toolbar {
+  flex: 0 0 auto;
+  display: flex;
+  align-items: center;
+  // Plus each button's own padding, this leaves ~12px between the glyphs.
+  gap: 2px;
+  margin-bottom: 8px;
+
+  // Center icons within a 24px minimum pointer target.
+  > button {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-width: 24px;
+    min-height: 24px;
+    height: auto;
+    padding: 0;
+    border: none;
+    background: transparent;
+  }
+
+  // Enlarge the filter glyph to visually match the settings icon.
+  .filter-icon {
+    font-size: 16px;
+  }
+}
+
+// Unshrinkable, so let it clip rather than wrap and eat the list's height.
+.hidden-count-notice {
+  flex: 0 0 auto;
+  font-family: monaco, serif;
+  font-size: 11px;
+  line-height: 14px;
+  color: rgba(0, 0, 0, 0.45);
+  margin-bottom: 4px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+// The overlay renders outside this component, so it is only reachable through
+// nzOverlayClassName. Items here are .ant-dropdown-menu-item, which the
+// .ant-menu-item rule in styles.scss does not cover.
+::ng-deep .console-type-filter {
+  li[nz-menu-item] {
+    user-select: none;
+  }
+}
+
 .console-list-container {
   flex: 1 1 auto;
   min-height: 0;
diff --git 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
index 82f8789be1..161967bfc5 100644
--- 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
@@ -374,6 +374,145 @@ describe("ConsoleFrameComponent", () => {
       
expect(fixture.debugElement.queryAll(By.css(".timestamp-tag")).length).toBe(0);
     });
 
+    it("hides the rows of a type switched off and leaves the other types 
rendered", () => {
+      component.consoleMessages = [withBody, noBody];
+      fixture.detectChanges();
+      
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(2);
+
+      component.setTypeVisibility("PRINT", false);
+      fixture.detectChanges();
+
+      const rows = 
fixture.debugElement.queryAll(By.css(".console-message-entry"));
+      expect(rows.length).toBe(1);
+      expect(fixture.nativeElement.textContent).toContain("plain B");
+      expect(fixture.nativeElement.textContent).not.toContain("header A");
+    });
+
+    it("keeps a message whose type has no configured filter visible", () => {
+      const unmapped = { ...consoleMessage("SOMETHING_NEW"), title: "unmapped 
C", message: "", workerId: "" };
+      component.consoleMessages = [withBody, unmapped];
+      component.setTypeVisibility("PRINT", false);
+      fixture.detectChanges();
+
+      
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(1);
+      expect(fixture.nativeElement.textContent).toContain("unmapped C");
+    });
+
+    it("reports how many messages the filter is hiding", () => {
+      component.consoleMessages = [withBody, noBody];
+      expect(component.hiddenMessageCount).toBe(0);
+
+      component.setTypeVisibility("PRINT", false);
+      fixture.detectChanges();
+
+      expect(component.hiddenMessageCount).toBe(1);
+      const notice = 
fixture.debugElement.query(By.css(".hidden-count-notice"));
+      expect(notice).toBeTruthy();
+      expect(notice.nativeElement.textContent).toContain("Hidden by filter: 
1");
+    });
+
+    it("restores the hidden rows when the type is switched back on", () => {
+      component.consoleMessages = [withBody, noBody];
+      component.setTypeVisibility("PRINT", false);
+      fixture.detectChanges();
+      
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(1);
+
+      component.setTypeVisibility("PRINT", true);
+      fixture.detectChanges();
+
+      
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(2);
+      expect(component.hiddenMessageCount).toBe(0);
+      
expect(fixture.debugElement.query(By.css(".hidden-count-notice"))).toBeNull();
+    });
+
+    it("applies the active filter to messages that arrive later", () => {
+      component.setTypeVisibility("PRINT", false);
+      component.consoleMessages = [withBody, noBody];
+      fixture.detectChanges();
+
+      
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(1);
+      expect(fixture.nativeElement.textContent).toContain("plain B");
+    });
+
+    it("keeps the filter when clearConsole empties the list", () => {
+      component.setTypeVisibility("PRINT", false);
+      component.consoleMessages = [withBody, noBody];
+      component.clearConsole();
+      component.consoleMessages = [withBody, noBody];
+      fixture.detectChanges();
+
+      expect(component.isTypeVisible("PRINT")).toBe(false);
+      
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(1);
+    });
+
+    it("keeps the filter when the bound operator changes", () => {
+      component.setTypeVisibility("PRINT", false);
+      getWorkerIds.mockReturnValue([]);
+      getConsoleMessages.mockReturnValue([withBody, noBody]);
+
+      component.ngOnChanges({ operatorId: { currentValue: "op2" } } as any);
+      fixture.detectChanges();
+
+      expect(component.isTypeVisible("PRINT")).toBe(false);
+      expect(component.hiddenMessageCount).toBe(1);
+    });
+
+    // jsdom performs no layout, so scrollHeight is always 0 and asserting on
+    // scrollTop's value would pass whether or not the component scrolled.
+    // These watch for the assignment itself.
+    function watchScroll(fixture: ComponentFixture<ConsoleFrameComponent>): () 
=> boolean {
+      const list = 
fixture.debugElement.query(By.css(".console-list-container")).nativeElement;
+      let scrolled = false;
+      Object.defineProperty(list, "scrollTop", {
+        configurable: true,
+        get: () => 0,
+        set: () => {
+          scrolled = true;
+        },
+      });
+      return () => scrolled;
+    }
+
+    it("does not follow the tail when the arriving message is filtered out", 
fakeAsync(() => {
+      const print = { ...withBody, msgType: { name: "PRINT" } };
+      const error = { ...noBody, msgType: { name: "ERROR" } };
+      component.setTypeVisibility("PRINT", false);
+      getConsoleMessages.mockReturnValue([print, error]);
+      component.displayConsoleMessages("op1");
+      tick();
+      fixture.detectChanges();
+
+      const scrolled = watchScroll(fixture);
+
+      getConsoleMessages.mockReturnValue([print, error, { ...print, title: 
"later print" }]);
+      component.displayConsoleMessages("op1");
+      tick();
+      fixture.detectChanges();
+
+      expect(scrolled()).toBe(false);
+      flush();
+    }));
+
+    it("follows the tail when a visible message arrives", fakeAsync(() => {
+      const print = { ...withBody, msgType: { name: "PRINT" } };
+      const error = { ...noBody, msgType: { name: "ERROR" } };
+      component.setTypeVisibility("PRINT", false);
+      getConsoleMessages.mockReturnValue([print, error]);
+      component.displayConsoleMessages("op1");
+      tick();
+      fixture.detectChanges();
+
+      const scrolled = watchScroll(fixture);
+
+      getConsoleMessages.mockReturnValue([print, error, { ...error, title: 
"later error" }]);
+      component.displayConsoleMessages("op1");
+      tick();
+      fixture.detectChanges();
+
+      expect(scrolled()).toBe(true);
+      flush();
+    }));
+
     it("does not render the debug input group when console input is disabled", 
() => {
       component.consoleInputEnabled = false;
       fixture.detectChanges();
@@ -525,6 +664,62 @@ describe("ConsoleFrameComponent settings dropdown", () => {
     }).compileComponents();
   });
 
+  /** Click a trigger and let the CDK overlay attach. */
+  function openMenu(fixture: ComponentFixture<ConsoleFrameComponent>, index: 
number): void {
+    
fixture.debugElement.queryAll(By.css("button[nz-dropdown]"))[index].nativeElement.click();
+    tick(300);
+    fixture.detectChanges();
+  }
+
+  const menuItemText = (overlayClass: string): (string | undefined)[] =>
+    Array.from(document.querySelectorAll(`.${overlayClass} 
li[nz-menu-item]`)).map(item => item.textContent?.trim());
+
+  const typeCheckboxes = (): HTMLElement[] =>
+    Array.from(document.querySelectorAll(".console-type-filter 
label[nz-checkbox]")) as HTMLElement[];
+
+  const checkboxOf = (label: HTMLElement): HTMLElement => {
+    const box = label.querySelector(".ant-checkbox");
+    expect(box).toBeTruthy();
+    return box as HTMLElement;
+  };
+
+  const isChecked = (label: HTMLElement): boolean => 
checkboxOf(label).classList.contains("ant-checkbox-checked");
+
+  const isIndeterminate = (label: HTMLElement): boolean =>
+    checkboxOf(label).classList.contains("ant-checkbox-indeterminate");
+
+  it("exposes both toolbar triggers as labelled buttons", fakeAsync(() => {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    fixture.detectChanges();
+
+    const triggers = fixture.debugElement.queryAll(By.css(".console-toolbar 
button[nz-dropdown]"));
+    expect(triggers.map(t => 
t.nativeElement.getAttribute("aria-label"))).toEqual([
+      "Display options",
+      "Filter by message type",
+    ]);
+    expect(triggers.every(t => t.nativeElement.tabIndex === 0)).toBe(true);
+    expect(triggers.every(t => t.nativeElement.getAttribute("aria-haspopup") 
=== "true")).toBe(true);
+    expect(triggers.map(t => 
t.nativeElement.getAttribute("aria-expanded"))).toEqual(["false", "false"]);
+
+    openMenu(fixture, 1);
+    
expect(triggers[1].nativeElement.getAttribute("aria-expanded")).toBe("true");
+
+    fixture.destroy();
+    flush();
+  }));
+
+  it("keeps the settings gear to display options only", fakeAsync(() => {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    fixture.detectChanges();
+
+    openMenu(fixture, 0);
+
+    expect(menuItemText("console-display-settings")).toEqual(["Show 
Timestamp", "Show Source"]);
+
+    fixture.destroy();
+    flush();
+  }));
+
   it("toggles the timestamp and source tags independently from the settings 
menu", fakeAsync(() => {
     const fixture = TestBed.createComponent(ConsoleFrameComponent);
     fixture.componentInstance.consoleMessages = [message];
@@ -537,15 +732,8 @@ describe("ConsoleFrameComponent settings dropdown", () => {
     expect(timestampTags()).toBe(1);
     expect(sourceTags()).toBe(1);
 
-    // hovering the gear attaches the dropdown overlay
-    fixture.debugElement
-      .query(By.css("a[nz-dropdown]"))
-      .nativeElement.dispatchEvent(new MouseEvent("mouseenter", { bubbles: 
true }));
-    tick(300);
-    fixture.detectChanges();
+    openMenu(fixture, 0);
 
-    const menuItems = 
Array.from(document.querySelectorAll("li[nz-menu-item]"));
-    expect(menuItems.map(item => item.textContent?.trim())).toEqual(["Show 
Timestamp", "Show Source"]);
     const switches = Array.from(document.querySelectorAll("nz-switch 
button.ant-switch")) as HTMLElement[];
     expect(switches.length).toBe(2);
     expect(switches[0].classList.contains("ant-switch-checked")).toBe(true);
@@ -557,20 +745,177 @@ describe("ConsoleFrameComponent settings dropdown", () 
=> {
     tick(300);
     fixture.detectChanges();
 
-    expect(switches[0].classList.contains("ant-switch-checked")).toBe(false);
     expect(timestampTags()).toBe(0);
     expect(sourceTags()).toBe(1);
 
-    // now flip "Show Source" as well
     switches[1].click();
     tick(300);
     fixture.detectChanges();
 
-    expect(switches[1].classList.contains("ant-switch-checked")).toBe(false);
     expect(sourceTags()).toBe(0);
     expect(timestampTags()).toBe(0);
 
     fixture.destroy();
     flush();
   }));
+
+  it("lists the four message types as title-case checkboxes under the filter 
icon", fakeAsync(() => {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    fixture.detectChanges();
+
+    openMenu(fixture, 1);
+
+    expect(menuItemText("console-type-filter")).toEqual(["Select all", 
"Print", "Command", "Debugger", "Error"]);
+    expect(typeCheckboxes().length).toBe(5);
+    // each option carries the same badge status as the rows it controls
+    expect(
+      Array.from(document.querySelectorAll(".console-type-filter 
.ant-badge-status-dot")).map(dot =>
+        Array.from(dot.classList).find(c => c.startsWith("ant-badge-status-") 
&& c !== "ant-badge-status-dot")
+      )
+    ).toEqual([
+      "ant-badge-status-default",
+      "ant-badge-status-processing",
+      "ant-badge-status-warning",
+      "ant-badge-status-error",
+    ]);
+    expect(typeCheckboxes().every(isChecked)).toBe(true);
+
+    fixture.destroy();
+    flush();
+  }));
+
+  it("removes the rows of a type when its checkbox is unticked", fakeAsync(() 
=> {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    const error: ConsoleMessage = { ...message, msgType: { name: "ERROR" }, 
title: "the one error" };
+    fixture.componentInstance.consoleMessages = [message, error];
+    fixture.detectChanges();
+
+    const rows = () => 
fixture.debugElement.queryAll(By.css(".console-message-entry")).length;
+    expect(rows()).toBe(2);
+
+    openMenu(fixture, 1);
+
+    // checkboxes are [Select all, Print, Command, Debugger, Error]
+    typeCheckboxes()[1].click();
+    tick(300);
+    fixture.detectChanges();
+
+    expect(rows()).toBe(1);
+    expect(fixture.nativeElement.textContent).toContain("the one error");
+    expect(fixture.componentInstance.hiddenMessageCount).toBe(1);
+
+    fixture.destroy();
+    flush();
+  }));
+
+  it("binds each checkbox to its own message type", fakeAsync(() => {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    const types = ["PRINT", "COMMAND", "DEBUGGER", "ERROR"];
+    fixture.componentInstance.consoleMessages = types.map(name => ({
+      ...message,
+      msgType: { name },
+      title: `title ${name}`,
+      message: "",
+      workerId: "",
+    }));
+    fixture.detectChanges();
+
+    openMenu(fixture, 1);
+    expect(typeCheckboxes().length).toBe(5);
+
+    // Check row content to catch swapped checkbox bindings.
+    types.forEach((name, index) => {
+      typeCheckboxes()[index + 1].click();
+      tick(300);
+      fixture.detectChanges();
+
+      const text = fixture.nativeElement.textContent as string;
+      expect(text).not.toContain(`title ${name}`);
+      types.slice(index + 1).forEach(remaining => 
expect(text).toContain(`title ${remaining}`));
+    });
+
+    
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(0);
+
+    fixture.destroy();
+    flush();
+  }));
+
+  it("stays open while several type checkboxes are unticked in a row", 
fakeAsync(() => {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    const error: ConsoleMessage = { ...message, msgType: { name: "ERROR" }, 
title: "the one error" };
+    fixture.componentInstance.consoleMessages = [message, error];
+    fixture.detectChanges();
+
+    openMenu(fixture, 1);
+
+    typeCheckboxes()[1].click();
+    tick(300);
+    fixture.detectChanges();
+    expect(typeCheckboxes().length).toBe(5);
+
+    typeCheckboxes()[4].click();
+    tick(300);
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.hiddenMessageCount).toBe(2);
+    
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(0);
+
+    fixture.destroy();
+    flush();
+  }));
+
+  it("unticks and restores every type from the select-all checkbox", 
fakeAsync(() => {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    fixture.componentInstance.consoleMessages = [message];
+    fixture.detectChanges();
+
+    openMenu(fixture, 1);
+    expect(typeCheckboxes().length).toBe(5);
+
+    typeCheckboxes()[0].click();
+    tick(300);
+    fixture.detectChanges();
+
+    expect(typeCheckboxes().slice(1).some(isChecked)).toBe(false);
+    
expect(fixture.debugElement.queryAll(By.css(".console-message-entry")).length).toBe(0);
+
+    typeCheckboxes()[0].click();
+    tick(300);
+    fixture.detectChanges();
+
+    expect(typeCheckboxes().slice(1).every(isChecked)).toBe(true);
+    expect(fixture.componentInstance.hiddenMessageCount).toBe(0);
+
+    fixture.destroy();
+    flush();
+  }));
+
+  it("shows select-all as indeterminate only while some types are hidden", 
fakeAsync(() => {
+    const fixture = TestBed.createComponent(ConsoleFrameComponent);
+    fixture.detectChanges();
+
+    openMenu(fixture, 1);
+
+    expect(isIndeterminate(typeCheckboxes()[0])).toBe(false);
+    expect(isChecked(typeCheckboxes()[0])).toBe(true);
+
+    typeCheckboxes()[1].click();
+    tick(300);
+    fixture.detectChanges();
+
+    expect(isIndeterminate(typeCheckboxes()[0])).toBe(true);
+
+    // all hidden is a definite state, not a mixed one
+    typeCheckboxes()[2].click();
+    typeCheckboxes()[3].click();
+    typeCheckboxes()[4].click();
+    tick(300);
+    fixture.detectChanges();
+
+    expect(isIndeterminate(typeCheckboxes()[0])).toBe(false);
+    expect(isChecked(typeCheckboxes()[0])).toBe(false);
+
+    fixture.destroy();
+    flush();
+  }));
 });
diff --git 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts
 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts
index a452ba7a03..bff3ae0771 100644
--- 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts
+++ 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.ts
@@ -29,10 +29,11 @@ import { isDefined } from 
"../../../../common/util/predicate";
 import { WorkflowWebsocketService } from 
"../../../service/workflow-websocket/workflow-websocket.service";
 import { NotificationService } from 
"../../../../common/service/notification/notification.service";
 import { UdfDebugService } from 
"../../../service/operator-debug/udf-debug.service";
-import { NzDropdownADirective, NzDropdownDirective, NzDropdownMenuComponent } 
from "ng-zorro-antd/dropdown";
+import { NzDropdownDirective, NzDropdownMenuComponent } from 
"ng-zorro-antd/dropdown";
 import { ɵNzTransitionPatchDirective } from 
"ng-zorro-antd/core/transition-patch";
 import { NzIconDirective } from "ng-zorro-antd/icon";
-import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu";
+import { NzMenuDirective, NzMenuDividerDirective, NzMenuItemComponent } from 
"ng-zorro-antd/menu";
+import { NzCheckboxComponent } from "ng-zorro-antd/checkbox";
 import { NzSwitchComponent } from "ng-zorro-antd/switch";
 import { FormsModule } from "@angular/forms";
 import { NzListComponent, NzListItemComponent } from "ng-zorro-antd/list";
@@ -54,13 +55,14 @@ import { NzSelectComponent, NzOptionComponent } from 
"ng-zorro-antd/select";
   templateUrl: "./console-frame.component.html",
   styleUrls: ["./console-frame.component.scss"],
   imports: [
-    NzDropdownADirective,
     NzDropdownDirective,
     ɵNzTransitionPatchDirective,
     NzIconDirective,
     NzDropdownMenuComponent,
     NzMenuDirective,
     NzMenuItemComponent,
+    NzMenuDividerDirective,
+    NzCheckboxComponent,
     NzSwitchComponent,
     FormsModule,
     NzListComponent,
@@ -90,8 +92,28 @@ export class ConsoleFrameComponent implements OnInit, 
OnChanges {
   @ViewChild(CdkVirtualScrollViewport) viewPort?: CdkVirtualScrollViewport;
   @ViewChild("consoleList", { read: ElementRef }) listElement?: ElementRef;
 
-  // display print
-  consoleMessages: ReadonlyArray<ConsoleMessage> = [];
+  // The template renders `filteredMessages`, recomputed whenever the messages
+  // or the filter change, so change detection never re-filters the list.
+  private rawConsoleMessages: ReadonlyArray<ConsoleMessage> = [];
+  filteredMessages: ReadonlyArray<ConsoleMessage> = [];
+
+  get consoleMessages(): ReadonlyArray<ConsoleMessage> {
+    return this.rawConsoleMessages;
+  }
+
+  set consoleMessages(messages: ReadonlyArray<ConsoleMessage>) {
+    this.rawConsoleMessages = messages;
+    this.applyTypeFilter();
+  }
+
+  // Message types the user has switched off. Absent from the set means 
visible,
+  // so a type the console has never seen before is shown rather than hidden.
+  private hiddenTypes = new Set<string>();
+
+  // Mirrors each dropdown's open state so the trigger can expose 
aria-expanded,
+  // which nz-dropdown does not set itself.
+  settingsMenuOpen = false;
+  typeFilterMenuOpen = false;
 
   // Configuration Menu items
   // TODO: move Configuration Menu to a separate component
@@ -111,6 +133,50 @@ export class ConsoleFrameComponent implements OnInit, 
OnChanges {
     ["ERROR", "error"],
   ]);
 
+  // The order here drives the order of the checkboxes in the filter menu.
+  readonly messageTypes: ReadonlyArray<string> = 
Array.from(this.labelMapping.keys());
+
+  isTypeVisible(type: string): boolean {
+    return !this.hiddenTypes.has(type);
+  }
+
+  setTypeVisibility(type: string, visible: boolean): void {
+    if (visible) {
+      this.hiddenTypes.delete(type);
+    } else {
+      this.hiddenTypes.add(type);
+    }
+    this.applyTypeFilter();
+  }
+
+  typeLabel(type: string): string {
+    return type.charAt(0) + type.slice(1).toLowerCase();
+  }
+
+  get allTypesVisible(): boolean {
+    return this.messageTypes.every(type => this.isTypeVisible(type));
+  }
+
+  get typeFilterIndeterminate(): boolean {
+    return !this.allTypesVisible && this.messageTypes.some(type => 
this.isTypeVisible(type));
+  }
+
+  setAllTypesVisible(visible: boolean): void {
+    this.hiddenTypes = visible ? new Set<string>() : new 
Set<string>(this.messageTypes);
+    this.applyTypeFilter();
+  }
+
+  get hiddenMessageCount(): number {
+    return this.rawConsoleMessages.length - this.filteredMessages.length;
+  }
+
+  private applyTypeFilter(): void {
+    this.filteredMessages =
+      this.hiddenTypes.size === 0
+        ? this.rawConsoleMessages
+        : this.rawConsoleMessages.filter(message => 
this.isTypeVisible(message.msgType.name));
+  }
+
   constructor(
     private executeWorkflowService: ExecuteWorkflowService,
     private workflowConsoleService: WorkflowConsoleService,
@@ -163,7 +229,13 @@ export class ConsoleFrameComponent implements OnInit, 
OnChanges {
   }
 
   displayConsoleMessages(operatorId: string): void {
+    const lastVisible = this.filteredMessages[this.filteredMessages.length - 
1];
     this.consoleMessages = operatorId ? 
this.workflowConsoleService.getConsoleMessages(operatorId) || [] : [];
+
+    // Preserve scroll position when no new visible message arrives.
+    if (this.filteredMessages[this.filteredMessages.length - 1] === 
lastVisible) {
+      return;
+    }
     setTimeout(() => {
       if (this.listElement) {
         this.listElement.nativeElement.scrollTop = 
this.listElement.nativeElement.scrollHeight;

Reply via email to