diff --git a/src/main/frontend/common/components/status-icon.spec.tsx b/src/main/frontend/common/components/status-icon.spec.tsx
index 9bdcb0f29..3f3b851e7 100644
--- a/src/main/frontend/common/components/status-icon.spec.tsx
+++ b/src/main/frontend/common/components/status-icon.spec.tsx
@@ -127,6 +127,23 @@ describe("StatusIcon", () => {
expect(result.current).to.equal(0);
unmount();
});
+ it("should continue progress when waitingForInput is true", async () => {
+ const { result, unmount } = renderHook(() => {
+ return useStageProgress({
+ ...mockStage,
+ state: Result.running,
+ waitingForInput: true,
+ startTimeMillis: now - 2_000,
+ previousTotalDurationMillis: 20_000,
+ });
+ });
+ expect(result.current).to.equal(10);
+ await act(() => vi.advanceTimersByTime(1_000));
+ expect(result.current).to.equal(15);
+ await act(() => vi.advanceTimersByTime(1_000));
+ expect(result.current).to.equal(20);
+ unmount();
+ });
});
describe("other states", function () {
for (const state in Result) {
diff --git a/src/main/frontend/common/components/status-icon.tsx b/src/main/frontend/common/components/status-icon.tsx
index c7b7aeb9c..079607000 100644
--- a/src/main/frontend/common/components/status-icon.tsx
+++ b/src/main/frontend/common/components/status-icon.tsx
@@ -10,11 +10,13 @@ import {
export function useStageProgress(stage: StageInfo) {
const [percentage, setPercentage] = useState(0);
useEffect(() => {
+ // If no longer running, immediately reset progress.
if (stage.state !== Result.running) {
- // percentage is only needed for the running icon.
setPercentage(0);
return;
}
+ // Keep updating progress while waitingForInput: the glyph changes to paused but
+ // the ring should continue to reflect underlying running progress.
const update = () => {
const currentTiming =
stage.totalDurationMillis ?? Date.now() - stage.startTimeMillis;
@@ -37,6 +39,7 @@ export function StageStatusIcon({ stage }: { stage: StageInfo }) {
return (
@@ -48,11 +51,15 @@ export function StageStatusIcon({ stage }: { stage: StageInfo }) {
*/
export default function StatusIcon({
status,
+ waitingForInput,
percentage,
skeleton,
}: StatusIconProps) {
const viewBoxSize = 512;
- const strokeWidth = status === "running" ? 50 : 0;
+ const iconStatus =
+ waitingForInput && status === Result.running ? Result.paused : status;
+ // Keep ring when underlying status is running (even if visually paused)
+ const strokeWidth = status === Result.running ? 50 : 0;
const radius = (viewBoxSize - strokeWidth) / 2.2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - ((percentage ?? 100) / 100) * circumference;
@@ -63,7 +70,7 @@ export default function StatusIcon({
className={"pgv-status-icon " + resultToColor(status, skeleton)}
opacity={skeleton ? 0.5 : 1}
role={"img"}
- aria-label={status}
+ aria-label={iconStatus}
>
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
{
startTimeMillis: 123,
totalDurationMillis: undefined,
pauseLiveTotal: false,
+ waitingForInput: false,
},
]);
expect(stages).to.not.equal(originalStages);
@@ -36,6 +37,7 @@ it("should handle skeleton stage with started children", () => {
startTimeMillis: 123,
totalDurationMillis: undefined,
pauseLiveTotal: false,
+ waitingForInput: false,
children: [
{
...mockStage,
@@ -44,6 +46,7 @@ it("should handle skeleton stage with started children", () => {
startTimeMillis: 123,
totalDurationMillis: undefined,
pauseLiveTotal: false,
+ waitingForInput: false,
},
],
},
@@ -84,6 +87,7 @@ it("should handle finished not_built stage", () => {
skeleton: false,
totalDurationMillis: undefined,
pauseLiveTotal: true,
+ waitingForInput: false,
},
]);
expect(stages).to.not.equal(originalStages);
@@ -111,6 +115,7 @@ it("should handle finished running stage", () => {
skeleton: false,
totalDurationMillis: undefined,
pauseLiveTotal: true,
+ waitingForInput: false,
},
]);
expect(stages).to.not.equal(originalStages);
@@ -149,6 +154,7 @@ it("should handle finished stage with children", () => {
totalDurationMillis: undefined,
skeleton: false,
pauseLiveTotal: true,
+ waitingForInput: false,
children: [
{
...mockStage,
@@ -158,6 +164,7 @@ it("should handle finished stage with children", () => {
totalDurationMillis: undefined,
skeleton: false,
pauseLiveTotal: true,
+ waitingForInput: false,
},
],
},
@@ -168,6 +175,60 @@ it("should handle finished stage with children", () => {
expect(stages[0].children[0]).to.not.equal(originalChildren[0]);
});
+it("should mark running stage waitingForInput when input step present", () => {
+ const originalStages: StageInfo[] = [
+ {
+ ...mockStage,
+ state: Result.running,
+ startTimeMillis: 42,
+ skeleton: false,
+ },
+ ];
+ const stages = refreshStagesFromSteps(originalStages, [
+ {
+ ...mockStep,
+ stageId: "1",
+ state: Result.running,
+ inputStep: {
+ message: "msg",
+ cancel: "Cancel",
+ id: "x",
+ ok: "OK",
+ parameters: false,
+ },
+ },
+ ]);
+ expect(stages[0].state).to.equal(Result.running);
+ expect(stages[0].waitingForInput).to.equal(true);
+});
+
+it("should bubble waitingForInput if child branch paused", () => {
+ const originalStages: StageInfo[] = [
+ {
+ ...mockStage,
+ state: Result.running,
+ startTimeMillis: 42,
+ skeleton: false,
+ children: [
+ {
+ ...mockStage,
+ id: 2,
+ state: Result.paused,
+ startTimeMillis: 50,
+ skeleton: false,
+ totalDurationMillis: undefined,
+ children: [],
+ pauseDurationMillis: 0,
+ url: "",
+ },
+ ],
+ },
+ ];
+ const stages = refreshStagesFromSteps(originalStages, []);
+ expect(stages[0].state).to.equal(Result.running);
+ expect(stages[0].waitingForInput).to.equal(true);
+});
+
const mockStage: StageInfo = {
name: "Build",
state: Result.not_built,
diff --git a/src/main/frontend/common/utils/refresh-stages-from-steps.ts b/src/main/frontend/common/utils/refresh-stages-from-steps.ts
index 8541e24a8..8fcbe81aa 100644
--- a/src/main/frontend/common/utils/refresh-stages-from-steps.ts
+++ b/src/main/frontend/common/utils/refresh-stages-from-steps.ts
@@ -29,6 +29,11 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) {
totalDurationMillis = undefined;
}
}
+ // Derived front-end flag: if any step has an inputStep or any child is paused/waiting, mark waitingForInput.
+ const waitingForInput =
+ state === Result.running &&
+ (stageSteps.some((s) => Boolean(s.inputStep)) ||
+ children.some((c) => c.waitingForInput || c.state === Result.paused));
// Best effort: Pause timer when a stage is expected to have finished to avoid having to decrement the total duration when done (as confirmed by the next run polling).
pauseLiveTotal =
startTimeMillis > 0 &&
@@ -40,7 +45,8 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) {
Boolean(stage.pauseLiveTotal) !== pauseLiveTotal ||
stage.startTimeMillis !== startTimeMillis ||
stage.state !== state ||
- stage.totalDurationMillis !== totalDurationMillis
+ stage.totalDurationMillis !== totalDurationMillis ||
+ Boolean(stage.waitingForInput) !== waitingForInput
) {
// Update in-place to avoid frequent re-render, then trigger re-render.
stage.children = children;
@@ -48,6 +54,7 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) {
stage.startTimeMillis = startTimeMillis;
stage.state = state;
stage.totalDurationMillis = totalDurationMillis;
+ stage.waitingForInput = waitingForInput;
if (!changed) stages = stages.slice();
changed = true;
stages[idx] = { ...stage };
diff --git a/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx b/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx
index c3a437505..3bc12fa9f 100644
--- a/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx
+++ b/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx
@@ -57,6 +57,11 @@ export interface StageInfo {
skeleton?: boolean;
pauseLiveTotal?: boolean;
+ /**
+ * Front-end derived flag indicating a running stage is awaiting input.
+ * Used to override the icon to the paused glyph while retaining the running state for filters & color.
+ */
+ waitingForInput?: boolean;
}
interface BaseNodeInfo {
diff --git a/src/main/frontend/setupTests.ts b/src/main/frontend/setupTests.ts
index 43b851665..2a5132539 100644
--- a/src/main/frontend/setupTests.ts
+++ b/src/main/frontend/setupTests.ts
@@ -2,6 +2,29 @@ import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
+// Ensure a functional localStorage implementation for tests that persist user preferences.
+if (
+ !("localStorage" in window) ||
+ typeof window.localStorage?.getItem !== "function" ||
+ typeof window.localStorage?.setItem !== "function"
+) {
+ const store: Record = {};
+ const localStoragePolyfill = {
+ getItem: (key: string) => (key in store ? store[key] : null),
+ setItem: (key: string, value: string) => {
+ store[key] = String(value);
+ },
+ removeItem: (key: string) => {
+ delete store[key];
+ },
+ clear: () => {
+ Object.keys(store).forEach((k) => delete store[k]);
+ },
+ };
+ // @ts-expect-error override for test environment
+ window.localStorage = localStoragePolyfill;
+}
+
const IntersectionObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),