Pulse a container while its subflow is working

In the parent's execution graph a container's own status is WAITING_FOR_SUBFLOW,
so it took none of the in-progress styling and sat there inert for minutes while
its child ran. Combined with a tree that was not refreshing, a working run was
indistinguishable from a stuck one.

The card now pulses on the same rhythm as a running node, in indigo rather than
yellow because the work is a level down: the node is not computing, it is
waiting on a child, and that child is the thing worth opening. A badge names the
iteration in flight - __containerIterationIndex is new on the graph payload - so
the signal says what is happening and not merely that something is.

An errored container does not pulse: there the error is what matters.

Motion is now suppressed under prefers-reduced-motion, with the pulses settling
on their emphasised frame so the state survives without the animation. That
covers the two pre-existing pulses as well - adding a third blinking element is
the point where honouring the setting stops being optional.

Budgets: task-step-node.css went 122 bytes over, so the badge icon lost its spin
(the border pulse already carries "in progress"). The initial bundle is still
2.21 kB over its 1.75 MB budget, up from being just inside it - reported, not
raised.

497 frontend tests green; the new assertions fail with the class binding removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 16:35:33 +02:00
parent 408bcb9309
commit 8659b798d5
5 changed files with 170 additions and 0 deletions

View File

@ -49,6 +49,17 @@
animation: llmRunningBorderPulse 0.95s ease-in-out infinite;
}
/*
* A container whose subflow is running. Same rhythm as .llm-node-running so the two read as one
* family of "in progress", in indigo rather than yellow because the work is a level down: the node
* is not computing, it is waiting on a child, and that is the thing worth opening.
*/
.llm-node-subflow-running {
border: 2px solid #6366f1;
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.45), 0 10px 24px rgba(15, 23, 42, 0.12);
animation: llmSubflowBorderPulse 0.95s ease-in-out infinite;
}
.llm-node-attention {
border: 2px solid #f97316;
animation: llmAttentionBorderPulse 1.2s ease-in-out infinite;
@ -311,6 +322,71 @@ button.llm-node-bias-summary:focus-visible {
}
}
@keyframes llmSubflowBorderPulse {
0%, 100% {
border-color: #818cf8;
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.12), 0 10px 24px rgba(15, 23, 42, 0.12);
}
50% {
border-color: #4f46e5;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.32), 0 10px 24px rgba(15, 23, 42, 0.18);
}
}
/*
* Anyone who has asked not to be shown motion still needs the state, so the pulses settle on their
* emphasised frame instead of animating. This covers the pre-existing pulses too: adding another
* blinking element is the point at which honouring the setting stops being optional.
*/
@media (prefers-reduced-motion: reduce) {
.llm-node-running,
.llm-node-subflow-running,
.llm-node-attention {
animation: none;
}
.llm-node-subflow-running {
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.32), 0 10px 24px rgba(15, 23, 42, 0.18);
}
.llm-node-running {
box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.32), 0 10px 24px rgba(15, 23, 42, 0.18);
}
}
/* Names the iteration in flight, so the pulse says what is happening and not only that it is. */
.llm-subflow-badge-wrap {
position: absolute;
top: -10px;
left: 12px;
z-index: 3;
pointer-events: none;
}
.llm-subflow-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px;
border: 1px solid #c7d2fe;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12);
pointer-events: auto;
cursor: help;
}
/* No spin on the icon: the border pulse already carries "in progress", and one moving thing beats
* two. It also kept this file inside its size budget. */
.llm-subflow-badge .bi {
font-size: 11px;
}
.llm-header {
display: flex;
align-items: center;

View File

@ -2,6 +2,7 @@
[class.llm-node--human]="isHumanNode()"
[class.llm-node-skipped]="isSkipped() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-running]="isRunning() && !hasExecutionErrors()"
[class.llm-node-subflow-running]="isSubflowRunning() && !hasExecutionErrors()"
[class.llm-node-completed]="isCompleted() && !needsAttention() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-attention]="needsAttention()"
[class.llm-node-error]="hasExecutionErrors()"
@ -22,6 +23,21 @@
}
</div>
}
@if (isSubflowRunning() && !hasExecutionErrors()) {
<div class="llm-subflow-badge-wrap">
<span class="llm-subflow-badge"
[title]="containerIterationIndex()
? 'Running iteration ' + containerIterationIndex() + ' in the subflow - open it in the execution tree'
: 'Running its subflow - open it in the execution tree'">
<i class="bi bi-arrow-repeat"></i>
@if (containerIterationIndex()) {
<span>Iteration {{ containerIterationIndex() }}</span>
} @else {
<span>Subflow</span>
}
</span>
</div>
}
@if (needsAttention() && isHumanNode()) {
<div class="llm-attention-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button

View File

@ -104,6 +104,64 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
fixture.detectChanges();
});
/**
* Re-renders the node with an amended step config. It replaces the whole `data` input rather than
* mutating it in place, because that is how the node is updated in the app - the graph hands the
* component a fresh payload - and because an OnPush component is not re-checked by a mutation
* nothing told it about.
*/
function setStepConfig(config: Record<string, unknown>) {
fixture.componentRef.setInput('data', {
...component.data,
data: {
...component.data.data,
specificConfiguration: { ...component.data.data.specificConfiguration, ...config }
}
});
fixture.detectChanges();
}
it('shows a container working on its subflow, which its own status never said', () => {
// The container's status is WAITING_FOR_SUBFLOW, so it took none of the in-progress styling
// and sat inert for minutes while its child ran - indistinguishable from a stuck run.
setStepConfig({ __stepStatus: 'WAITING_FOR_SUBFLOW', __containerIterationIndex: 3 });
expect(component.isSubflowRunning()).toBe(true);
expect(component.isRunning()).toBe(false);
const card = fixture.nativeElement.querySelector('.llm-node');
expect(card.classList).toContain('llm-node-subflow-running');
// And it names the iteration, so the signal says what is happening, not only that it is.
expect(fixture.nativeElement.querySelector('.llm-subflow-badge').textContent).toContain('Iteration 3');
});
it('still marks a container as working when no iteration index is reported', () => {
// A generic container runs one subflow with no iteration number; it must not go dark.
setStepConfig({ __stepStatus: 'WAITING_FOR_SUBFLOW', __containerIterationIndex: null });
expect(component.containerIterationIndex()).toBeNull();
expect(fixture.nativeElement.querySelector('.llm-node').classList)
.toContain('llm-node-subflow-running');
expect(fixture.nativeElement.querySelector('.llm-subflow-badge').textContent).toContain('Subflow');
});
it('keeps the two in-progress states apart', () => {
setStepConfig({ __stepStatus: 'RUNNING' });
expect(component.isSubflowRunning()).toBe(false);
const card = fixture.nativeElement.querySelector('.llm-node');
expect(card.classList).toContain('llm-node-running');
expect(card.classList).not.toContain('llm-node-subflow-running');
expect(fixture.nativeElement.querySelector('.llm-subflow-badge')).toBeNull();
});
it('does not pulse a container that failed, where the error is what matters', () => {
setStepConfig({ __stepStatus: 'WAITING_FOR_SUBFLOW', __executionErrors: ['subflow blew up'] });
expect(fixture.nativeElement.querySelector('.llm-node').classList)
.not.toContain('llm-node-subflow-running');
expect(fixture.nativeElement.querySelector('.llm-subflow-badge')).toBeNull();
});
it('flags a node as bias-active when it has active annotation ids in its execution config', () => {
expect(component.isBiasActive()).toBe(true);
});

View File

@ -552,6 +552,21 @@ export class TaskStepNodeComponent {
return this.stepStatus() === 'RUNNING';
}
/**
* A container delegating to a subflow. Its own status is WAITING_FOR_SUBFLOW, so it took none of
* the in-progress styling and sat there looking inert for minutes while its child worked - which
* is indistinguishable from a stuck run.
*/
isSubflowRunning(): boolean {
return this.stepStatus() === 'WAITING_FOR_SUBFLOW';
}
/** The iteration a working container is on, so the signal says what, not just that. */
containerIterationIndex(): number | null {
const index = this.blockConfiguration?.['__containerIterationIndex'];
return typeof index === 'number' ? index : null;
}
isSkipped(): boolean {
return this.stepStatus() === 'SKIPPED';
}

View File

@ -448,6 +448,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionStatus: this.execution()?.context.status ?? null,
__interactionSimulationEnabled: this.execution()?.interactionSimulationEnabled === true,
__stepStatus: step.status,
// Which iteration a container is on, so a node that is visibly working can say what it
// is working on rather than only that it is.
__containerIterationIndex: typeof step.containerIterationIndex === 'number'
? step.containerIterationIndex
: null,
__stepSkipReason: step.skipReason ?? null,
__stepSimulated: step.simulated === true,
__stepUserInteractive: stepNode.userInteractive === true,