From 323115fd407e7777a82c0128564b22ea9b779ffc Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 11:25:15 +0200 Subject: [PATCH] Disable the execution tree toggle when there is no subtree The tree renders nothing for a run without container steps, so the panel offered to open onto an empty box. The toggle is now inert in that case, greyed, with a tooltip saying why, and the chevron - which promised something to unfold - is gone. The host decides "is there anything to show" with the tree's own rule rather than a second guess at it: the container-step test moved out of the component into an exported function taking the container-type predicate, so both call one definition. Guessing separately is how a panel ends up claiming content the tree will not draw. An existing test caught the behaviour change, which is the right outcome: it is rewritten to cover both halves - inert with no run selected, and toggling once a run actually has a container step. Co-Authored-By: Claude Opus 5 (1M context) --- .../layouts/tasks-executor/tasks-executor.css | 8 ++++ .../tasks-executor/tasks-executor.html | 11 +++-- .../tasks-executor/tasks-executor.spec.ts | 38 ++++++++++++++++- .../layouts/tasks-executor/tasks-executor.ts | 14 ++++++- .../execution-tree/execution-tree.spec.ts | 42 ++++++++++++++++++- .../shared/execution-tree/execution-tree.ts | 30 ++++++++++--- 6 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/app/layouts/tasks-executor/tasks-executor.css b/src/app/layouts/tasks-executor/tasks-executor.css index bace9dd..15e19e6 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.css +++ b/src/app/layouts/tasks-executor/tasks-executor.css @@ -50,6 +50,14 @@ box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06); } +/* No subtree to open: say so by looking inert rather than by opening onto an empty panel. */ +.tasks-executor-tree-toggle:disabled { + border-color: #e8edf3; + background: #f8fafc; + color: #94a3b8; + cursor: default; +} + .tasks-executor-tree-toggle-icon { display: inline-flex; align-items: center; diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html index 061021a..2c92436 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.html +++ b/src/app/layouts/tasks-executor/tasks-executor.html @@ -10,17 +10,22 @@ @if (selectedExecution(); as run) { -
+
- @if (executionTreeOpen()) { + @if (executionTreeAvailable() && executionTreeOpen()) { { let component: TasksExecutor; let fixture: ComponentFixture; + /** Hoisted so a test can feed the component a run; the component only exposes it read-only. */ + let taskExecutions: ReturnType>; beforeEach(async () => { + taskExecutions = signal([]); await TestBed.configureTestingModule({ imports: [TasksExecutor], providers: [ @@ -41,7 +44,7 @@ describe('TasksExecutor', () => { { provide: TaskExecutionsService, useValue: { - taskExecutions: signal([]), + taskExecutions, taskExecutionGroups: signal([]), followedExecutions: signal({}), pendingExecutionCreation: signal(false), @@ -105,8 +108,39 @@ describe('TasksExecutor', () => { expect(component).toBeTruthy(); }); - it('toggles the execution tree panel', () => { + it('does not let the tree panel be opened onto nothing', () => { + // No run selected, so no container steps: the toggle is inert rather than opening an empty panel. + expect(component.executionTreeAvailable()).toBe(false); + + component.toggleExecutionTree(); expect(component.executionTreeOpen()).toBe(true); + }); + + it('toggles the execution tree panel once a run has a subtree', () => { + component.selectedExecutionId.set('parent-1'); + taskExecutions.set([{ + id: 'parent-1', + name: 'Parent', + creationTime: 1, + context: { + inputs: {}, + result: {}, + errors: {}, + warnings: {}, + status: 'RUNNING', + waitingSteps: [], + steps: { + 'step-1': { + id: 'step-1', + status: 'RUNNING', + simulated: false, + node: { nodeFamily: 'container' } + } + } + } + } as any]); + + expect(component.executionTreeAvailable()).toBe(true); component.toggleExecutionTree(); expect(component.executionTreeOpen()).toBe(false); diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 141fc7a..e8c71e5 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -22,7 +22,8 @@ import { TasksExecutionsListComponent } from '@shared/tasks-executions-list/tasks-executions-list'; import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer'; -import { ExecutionTreeComponent, ExecutionTreeSelection } from '@shared/execution-tree/execution-tree'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ExecutionTreeComponent, ExecutionTreeSelection, executionTreeHasContent } from '@shared/execution-tree/execution-tree'; import { formatDuration } from '@shared/task-execution-viewer/execution-viewer.utils'; import { BlocksService } from '@services/blocks/blocks'; import { ContainersService } from '@services/containers/containers'; @@ -65,7 +66,7 @@ export function findInteractiveSubflowTargets( @Component({ selector: 'app-tasks-executor', - imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, ExecutionTreeComponent, MatCardModule], + imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, ExecutionTreeComponent, MatCardModule, MatTooltipModule], templateUrl: './tasks-executor.html', styleUrl: './tasks-executor.css', changeDetection: ChangeDetectionStrategy.OnPush @@ -97,6 +98,14 @@ export class TasksExecutor { readonly requestedExecutionId = signal(null); readonly executionTreeOpen = signal(true); + /** + * A run with no container steps has no subtree to show, so the panel would open onto nothing. + * Uses the tree's own rule, not a second guess at it. + */ + readonly executionTreeAvailable = computed(() => executionTreeHasContent( + this.selectedExecution(), + (typeName) => !!this.containersService.peekContainerType(typeName))); + readonly selectedExecution = computed(() => { const selectedId = this.selectedExecutionId(); const details = this.executionDetails(); @@ -253,6 +262,7 @@ export class TasksExecutor { } toggleExecutionTree() { + if (!this.executionTreeAvailable()) return; this.executionTreeOpen.update((open) => !open); } diff --git a/src/app/shared/execution-tree/execution-tree.spec.ts b/src/app/shared/execution-tree/execution-tree.spec.ts index 6b6af3e..8a74a9d 100644 --- a/src/app/shared/execution-tree/execution-tree.spec.ts +++ b/src/app/shared/execution-tree/execution-tree.spec.ts @@ -6,7 +6,7 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; -import { ExecutionTreeComponent } from './execution-tree'; +import { ExecutionTreeComponent, executionTreeHasContent } from './execution-tree'; function containerStep(overrides: Partial = {}) { return { @@ -187,3 +187,43 @@ describe('ExecutionTreeComponent', () => { expect(component.isExecutionExpanded('root-2')).toBe(true); }); }); + +describe('executionTreeHasContent', () => { + const known = (typeName: string) => typeName === 'IteratorContainer'; + + function run(steps: Record): any { + return { id: 'e1', name: 'run', creationTime: 0, context: { steps } }; + } + + it('reports nothing to show for a run without container steps', () => { + expect(executionTreeHasContent(run({ + s1: { id: 's1', status: 'COMPLETED', simulated: false, node: { nodeFamily: 'block' } } + }), known)).toBe(false); + }); + + it('reports nothing for a missing run or a run with no steps', () => { + expect(executionTreeHasContent(null, known)).toBe(false); + expect(executionTreeHasContent(run({}), known)).toBe(false); + }); + + it('detects an explicit container node', () => { + expect(executionTreeHasContent(run({ + s1: { id: 's1', status: 'RUNNING', simulated: false, node: { nodeFamily: 'container' } } + }), known)).toBe(true); + }); + + it('detects a step that already spawned a child execution', () => { + expect(executionTreeHasContent(run({ + s1: { id: 's1', status: 'RUNNING', simulated: false, activeInnerExecutionId: 'child' } + }), known)).toBe(true); + }); + + it('detects a container by its registered type name', () => { + expect(executionTreeHasContent(run({ + s1: { id: 's1', status: 'READY', simulated: false, node: { typeName: 'IteratorContainer' } } + }), known)).toBe(true); + expect(executionTreeHasContent(run({ + s1: { id: 's1', status: 'READY', simulated: false, node: { typeName: 'LLMBlock' } } + }), known)).toBe(false); + }); +}); diff --git a/src/app/shared/execution-tree/execution-tree.ts b/src/app/shared/execution-tree/execution-tree.ts index 469e959..70c1abd 100644 --- a/src/app/shared/execution-tree/execution-tree.ts +++ b/src/app/shared/execution-tree/execution-tree.ts @@ -34,6 +34,29 @@ export type ContainerStepView = { * TaskExecutionsService.retrieveStepIterations). Each iteration is itself a full * execution that may contain further container steps, so the tree recurses. */ +/** + * Whether a step opens a subtree. Exported as a function taking the container-type predicate rather + * than living only on the component, so the panel hosting the tree decides "is there anything to + * show" with the very same rule the tree uses to show it - otherwise the two drift and the panel + * offers to open onto nothing. + */ +export function isContainerStep(step: TaskExecutionStep, + isKnownContainerType: (typeName: string) => boolean): boolean { + if (step.node?.nodeFamily === 'container') return true; + if (step.activeInnerExecutionId) return true; + if (step.containerContinuationPhase) return true; + if (typeof step.containerIterationIndex === 'number') return true; + const typeName = step.node?.typeName; + return !!typeName && isKnownContainerType(typeName); +} + +export function executionTreeHasContent(execution: TaskExecution | null | undefined, + isKnownContainerType: (typeName: string) => boolean): boolean { + if (!execution) return false; + return Object.values(execution.context.steps ?? {}) + .some((step) => isContainerStep(step, isKnownContainerType)); +} + @Component({ selector: 'app-execution-tree', imports: [CommonModule], @@ -92,12 +115,7 @@ export class ExecutionTreeComponent { } private isContainerStep(step: TaskExecutionStep): boolean { - if (step.node?.nodeFamily === 'container') return true; - if (step.activeInnerExecutionId) return true; - if (step.containerContinuationPhase) return true; - if (typeof step.containerIterationIndex === 'number') return true; - const typeName = step.node?.typeName; - return !!typeName && !!this.containersService.peekContainerType(typeName); + return isContainerStep(step, (typeName) => !!this.containersService.peekContainerType(typeName)); } /** Iterations for a step, minus GUARD subflows (debug-only, see backend doc). */