Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8147,15 +8147,19 @@ window.closePricingModal = closePricingModal;
// The three-stage view is bound to the real Architect -> Generator -> Review
// events, so nothing here invents an outcome: the track advances only when a
// stage actually reports, a stage that never ran is never drawn as completed,
// and a failure never falls through to a result. The elapsed counter is the
// only time-driven element, and it reports real elapsed time.
// and a failure never falls through to a result. The run's one timer drives
// both the elapsed counter (real time) and the fill's in-slice easing (an
// estimate that never completes a stage ahead of its response).
let pipelineElapsedTimer = null;
let pipelineStartTime = null;
// Every view update carries the run it belongs to (pipelineState.runId), so a
// response that arrives after the user started a newer run is discarded
// instead of overwriting the newer run's state.
let pipelineStageStates = { 1: "pending", 2: "pending", 3: "pending" };
let pipelineActiveStage = 1;
// When the active stage's slice of the track started easing; each stage
// entry re-arms it.
let pipelineStageStartedAt = null;
let pipelineSelectedStage = null;

const PIPELINE_STAGE_LABELS = {
Expand Down Expand Up @@ -8301,12 +8305,49 @@ function completedPipelineStageCount() {
function renderPipelineTrack() {
const fillEl = document.getElementById("pipeline-progress-fill");
if (fillEl) {
fillEl.style.width = `${(completedPipelineStageCount() / 3) * 100}%`;
if (pipelineStageStates[pipelineActiveStage] === "failed") {
// Pin the fill where it visibly is. Read the rendered width BEFORE
// disabling the transition — disabling it first snaps the element to
// its pending target — and freeze as a track percentage so a later
// resize keeps the same proportion instead of a stale pixel count.
const rendered = getComputedStyle(fillEl).width;
const track = fillEl.parentElement;
const trackPx = track ? track.getBoundingClientRect().width : 0;
const renderedPx = parseFloat(rendered) || 0;
fillEl.style.transition = "none";
fillEl.style.width = trackPx ? `${(renderedPx / trackPx) * 100}%` : rendered;
} else {
fillEl.style.transition = "";
fillEl.style.width = `${pipelineTrackPercent()}%`;
}
}
const countEl = document.getElementById("progress-stage-count");
if (countEl) countEl.textContent = `Step ${pipelineActiveStage} of 3`;
}

/**
* Each stage owns a third of the track. While a stage runs the fill eases
* asymptotically toward 95% of its slice, so a slow stage still looks alive
* without the bar ever claiming the stage finished ahead of its response.
* A finished stage sits on its own boundary — a stage-2 re-entry (refine,
* pasted errors) whose Review completes lands on 100%, not the count of
* stages that ran.
*/
function pipelineTrackPercent() {
const span = 100 / 3;
const state = pipelineStageStates[pipelineActiveStage];
if (state === "active" && pipelineStageStartedAt !== null) {
const elapsed = (Date.now() - pipelineStageStartedAt) / 1000;
return (
(pipelineActiveStage - 1) * span +
span * 0.95 * (1 - Math.exp(-elapsed / 12))
);
}
return state === "done"
? (pipelineActiveStage / 3) * 100
: (completedPipelineStageCount() / 3) * 100;
}

/**
* Write the status line. It lives inside a polite live region, so writing it is
* also how the run is announced to assistive technology.
Expand Down Expand Up @@ -8477,6 +8518,7 @@ function showPipelineProgress(options = {}) {
function updatePipelineProgressStep(step, runId) {
if (!isCurrentPipelineRun(runId)) return;
pipelineActiveStage = step;
pipelineStageStartedAt = Date.now();
clearPipelineStageSelection();

[1, 2, 3].forEach((candidate) => {
Expand Down Expand Up @@ -8627,6 +8669,8 @@ function startProgressTimer() {
const elapsed = (Date.now() - pipelineStartTime) / 1000;
const elapsedEl = document.getElementById("progress-elapsed");
if (elapsedEl) elapsedEl.textContent = `${Math.floor(elapsed)}s`;
// The same tick drives the fill's in-slice easing.
renderPipelineTrack();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
}, 250);
}

Expand Down
119 changes: 119 additions & 0 deletions e2e/stu377-pipeline-progress.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,60 @@ test.describe("Generation progress binds to real stage events", () => {
pipeline.release("review");
await expect(page.locator("#results-view")).toHaveClass(/visible/);
});

test("the track eases through each stage's slice instead of jumping", async ({ page }) => {
await openHome(page);
const pipeline = await routePipelineStages(page, {
hold: ["architect", "generator", "review"],
});

await page.locator("#pipeline-input").fill("A gauge widget");
await page.locator("#hero-send").click();
await expect(stage(page, 1)).toHaveAttribute("data-state", "active");

// Sample the fill while stage 1 is held: it moves continuously inside
// the stage's third of the track, never claiming the slice is done.
const widths = await page.evaluate(async () => {
const seen = [];
const deadline = Date.now() + 2500;
const fill = document.getElementById("pipeline-progress-fill");
while (Date.now() < deadline) {
const value = parseFloat(fill.style.width) || 0;
if (seen[seen.length - 1] !== value) seen.push(value);
await new Promise((resolve) => setTimeout(resolve, 100));
}
return seen;
});
expect(widths.length).toBeGreaterThan(1);
for (let index = 1; index < widths.length; index += 1) {
expect(widths[index]).toBeGreaterThan(widths[index - 1]);
}
expect(widths[widths.length - 1]).toBeLessThan(100 / 3);

const trackWidth = () =>
page.evaluate(
() => parseFloat(document.getElementById("pipeline-progress-fill").style.width) || 0,
);

// Completing the stage lands the bar on its real boundary, and the next
// stage eases forward from there rather than snapping to the end.
pipeline.release("architect");
await expect(stage(page, 2)).toHaveAttribute("data-state", "active");
await expect.poll(trackWidth).toBeGreaterThan(100 / 3 + 0.5);
// A held stage 2 keeps easing but never claims stage 3's slice.
await page.waitForTimeout(1200);
expect(await trackWidth()).toBeLessThan(200 / 3);

pipeline.release("generator");
await expect(stage(page, 3)).toHaveAttribute("data-state", "active");
await expect.poll(trackWidth).toBeGreaterThan((200 / 3) + 0.5);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
// And stage 3 never claims the run finished ahead of its response.
await page.waitForTimeout(1200);
expect(await trackWidth()).toBeLessThan(100);

pipeline.release("review");
await expect(page.locator("#results-view")).toHaveClass(/visible/);
});
});

test.describe("Recoverable failure states", () => {
Expand Down Expand Up @@ -407,6 +461,64 @@ test.describe("Recoverable failure states", () => {
{ url: "https://img.example/ref.png" },
]);
});

test("a failed stage freezes the fill mid-transition instead of letting it drift", async ({
page,
}) => {
await openHome(page);
const pipeline = await routePipelineStages(page, {
hold: ["generator"],
responses: { generator: providerError },
});

await page.locator("#pipeline-input").fill("A gauge widget");
await page.locator("#hero-send").click();
await expect(stage(page, 2)).toHaveAttribute("data-state", "active");

// Let the fill get moving inside stage 2's slice before the failure.
await expect
.poll(async () =>
page.evaluate(
() => parseFloat(document.getElementById("pipeline-progress-fill").style.width) || 0,
),
)
.toBeGreaterThan(100 / 3 + 0.5);

// The 700ms width transition must not keep animating after the run dies:
// the fill is pinned at its rendered position and stops moving. The pin
// is a track percentage, so a later resize keeps the same proportion.
pipeline.release("generator");
await expect(failure(page)).toBeVisible();
await expect
.poll(async () =>
page.evaluate(() => document.getElementById("pipeline-progress-fill").style.width),
)
.toMatch(/%$/);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
const frozenWidth = () =>
page.evaluate(
() => getComputedStyle(document.getElementById("pipeline-progress-fill")).width,
);
const first = await frozenWidth();
await page.waitForTimeout(900);
expect(await frozenWidth()).toBe(first);

// Resizing the track keeps the frozen proportion: the fill's share of
// the track survives a narrower viewport instead of a stale pixel count.
const geometry = () =>
page.evaluate(() => {
const fill = document.getElementById("pipeline-progress-fill");
const track = fill?.parentElement;
if (!fill || !track) return null;
const trackWidth = track.getBoundingClientRect().width;
return { trackWidth, share: fill.getBoundingClientRect().width / trackWidth };
});
const before = await geometry();
await page.setViewportSize({ width: 360, height: 800 });
await page.waitForTimeout(150);
const after = await geometry();
expect(after.trackWidth).toBeLessThan(before.trackWidth);
expect(Math.abs(after.share - before.share)).toBeLessThan(0.01);
});
});

test.describe("Stateful transitions stay isolated per run", () => {
Expand Down Expand Up @@ -496,6 +608,13 @@ test.describe("Stateful transitions stay isolated per run", () => {
await expect(page.locator("#pipeline-progress")).toHaveClass(/visible/);

refinement.release("generator");
// A stage-2 re-entry still finishes on 100%: the skipped Architect does
// not drag the completed run's bar back to two thirds.
await expect
.poll(async () =>
page.evaluate(() => document.getElementById("pipeline-progress-fill").style.width),
)
.toBe("100%");
await expect(page.locator("#results-view")).toHaveClass(/visible/);
await expect(stage(page, 3)).toHaveAttribute("data-state", "done");
await expect(stage(page, 1)).toHaveAttribute("data-state", "skipped");
Expand Down
Loading