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()) {