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:
parent
0dcdc6d135
commit
323115fd40
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue