From 8659b798d572cffc879d2bd581b74bb7543d0990 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 16:35:33 +0200 Subject: [PATCH] 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) --- .../nodes/task-step-node/task-step-node.css | 76 +++++++++++++++++++ .../nodes/task-step-node/task-step-node.html | 16 ++++ .../task-step-node/task-step-node.spec.ts | 58 ++++++++++++++ .../nodes/task-step-node/task-step-node.ts | 15 ++++ .../task-execution-viewer.ts | 5 ++ 5 files changed, 170 insertions(+) diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index e02aa7a..8201a41 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -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; diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 3277c04..9eb4e7e 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -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 @@ } } + @if (isSubflowRunning() && !hasExecutionErrors()) { +
+ + + @if (containerIterationIndex()) { + Iteration {{ containerIterationIndex() }} + } @else { + Subflow + } + +
+ } @if (needsAttention() && isHumanNode()) {