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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 11:25:15 +02:00
parent 0dcdc6d135
commit 323115fd40
6 changed files with 129 additions and 14 deletions

View File

@ -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;

View File

@ -10,17 +10,22 @@
</app-tasks-executions-list>
@if (selectedExecution(); as run) {
<section class="tasks-executor-tree-panel" [class.tasks-executor-tree-panel-collapsed]="!executionTreeOpen()">
<section class="tasks-executor-tree-panel"
[class.tasks-executor-tree-panel-collapsed]="!executionTreeAvailable() || !executionTreeOpen()">
<button
type="button"
class="tasks-executor-tree-toggle"
[attr.aria-expanded]="executionTreeOpen()"
[disabled]="!executionTreeAvailable()"
[attr.aria-expanded]="executionTreeAvailable() && executionTreeOpen()"
[matTooltip]="executionTreeAvailable() ? '' : 'This run has no container steps, so there is no subtree to show.'"
(click)="toggleExecutionTree()">
<span>Execution tree</span>
@if (executionTreeAvailable()) {
<span class="tasks-executor-tree-toggle-icon" [class.tasks-executor-tree-toggle-icon-open]="executionTreeOpen()">⌃</span>
}
</button>
@if (executionTreeOpen()) {
@if (executionTreeAvailable() && executionTreeOpen()) {
<app-execution-tree
class="tasks-executor-rail-tree"
[rootExecution]="run"

View File

@ -17,8 +17,11 @@ import { findInteractiveSubflowTargets, TasksExecutor } from './tasks-executor';
describe('TasksExecutor', () => {
let component: TasksExecutor;
let fixture: ComponentFixture<TasksExecutor>;
/** Hoisted so a test can feed the component a run; the component only exposes it read-only. */
let taskExecutions: ReturnType<typeof signal<TaskExecution[]>>;
beforeEach(async () => {
taskExecutions = signal<TaskExecution[]>([]);
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);

View File

@ -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<string | null>(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<TaskExecution | null>(() => {
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);
}

View File

@ -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<TaskExecution['context']['steps'][string]> = {}) {
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<string, any>): 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);
});
});

View File

@ -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). */