From aa00529bbe63f2645894a6862c853c88b4fafde7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:02:09 +0000 Subject: [PATCH 1/4] fix: pipeline progress bar eases inside each stage's slice The fill jumped between stage boundaries (doneCount/3) so nothing moved while a stage ran. Each stage now owns a third of the track and the fill eases asymptotically toward 95% of its slice on the existing 250ms tick, landing on the real boundary only when the stage's response arrives. Failed stages freeze where they eased; skipped slices (refine re-entry) start consumed. Co-Authored-By: Stuart Gardoll --- app.js | 31 +++++++++++++++++- e2e/stu377-pipeline-progress.spec.js | 48 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index c03060f..e9579dd 100644 --- a/app.js +++ b/app.js @@ -8156,6 +8156,9 @@ let pipelineStartTime = null; // 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 = { @@ -8301,12 +8304,35 @@ function completedPipelineStageCount() { function renderPipelineTrack() { const fillEl = document.getElementById("pipeline-progress-fill"); if (fillEl) { - fillEl.style.width = `${(completedPipelineStageCount() / 3) * 100}%`; + 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 failed stage freezes where it eased to; completed stages sit on their + * real boundaries. + */ +function pipelineTrackPercent() { + const span = 100 / 3; + const state = pipelineStageStates[pipelineActiveStage]; + if ( + (state === "active" || state === "failed") && + pipelineStageStartedAt !== null + ) { + const elapsed = (Date.now() - pipelineStageStartedAt) / 1000; + return ( + (pipelineActiveStage - 1) * span + + span * 0.95 * (1 - Math.exp(-elapsed / 12)) + ); + } + return (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. @@ -8477,6 +8503,7 @@ function showPipelineProgress(options = {}) { function updatePipelineProgressStep(step, runId) { if (!isCurrentPipelineRun(runId)) return; pipelineActiveStage = step; + pipelineStageStartedAt = Date.now(); clearPipelineStageSelection(); [1, 2, 3].forEach((candidate) => { @@ -8627,6 +8654,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(); }, 250); } diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index 03cb00c..e482353 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -161,6 +161,54 @@ 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); + + pipeline.release("generator"); + await expect(stage(page, 3)).toHaveAttribute("data-state", "active"); + await expect.poll(trackWidth).toBeGreaterThan((200 / 3) + 0.5); + + pipeline.release("review"); + await expect(page.locator("#results-view")).toHaveClass(/visible/); + }); }); test.describe("Recoverable failure states", () => { From 4b82cb1643c0ed40349a0ac10742f08b62732b60 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:12:34 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20review=20follow-ups=20=E2=80=94=20fr?= =?UTF-8?q?eeze=20fill=20on=20failure,=20done=20stages=20land=20on=20their?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Failed runs pin the fill's rendered width with the transition disabled, so the bar stops where it visibly was instead of drifting 700ms more. - A completed active stage sits on its own boundary (activeStage/3), so stage-2 re-entry runs finish on 100% instead of retreating to 66.7%. - e2e: held stages 2/3 assert upper ceilings; a new spec pins the frozen width across the transition window; the refine spec asserts a 100% finish. - Timer comment now covers both elements it drives. Co-Authored-By: Stuart Gardoll --- app.js | 29 +++++++++++------ e2e/stu377-pipeline-progress.spec.js | 48 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/app.js b/app.js index e9579dd..b8a1272 100644 --- a/app.js +++ b/app.js @@ -8147,8 +8147,9 @@ 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 @@ -8304,7 +8305,15 @@ function completedPipelineStageCount() { function renderPipelineTrack() { const fillEl = document.getElementById("pipeline-progress-fill"); if (fillEl) { - fillEl.style.width = `${pipelineTrackPercent()}%`; + if (pipelineStageStates[pipelineActiveStage] === "failed") { + // Pin the fill where it visibly is: writing a newer % target would let + // the 700ms width transition keep animating after the run died. + fillEl.style.transition = "none"; + fillEl.style.width = getComputedStyle(fillEl).width; + } else { + fillEl.style.transition = ""; + fillEl.style.width = `${pipelineTrackPercent()}%`; + } } const countEl = document.getElementById("progress-stage-count"); if (countEl) countEl.textContent = `Step ${pipelineActiveStage} of 3`; @@ -8314,23 +8323,23 @@ function renderPipelineTrack() { * 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 failed stage freezes where it eased to; completed stages sit on their - * real boundaries. + * 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" || state === "failed") && - pipelineStageStartedAt !== null - ) { + if (state === "active" && pipelineStageStartedAt !== null) { const elapsed = (Date.now() - pipelineStageStartedAt) / 1000; return ( (pipelineActiveStage - 1) * span + span * 0.95 * (1 - Math.exp(-elapsed / 12)) ); } - return (completedPipelineStageCount() / 3) * 100; + return state === "done" + ? (pipelineActiveStage / 3) * 100 + : (completedPipelineStageCount() / 3) * 100; } /** diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index e482353..65405ef 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -201,10 +201,16 @@ test.describe("Generation progress binds to real stage events", () => { 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); + // 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/); @@ -455,6 +461,41 @@ 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. + pipeline.release("generator"); + await expect(failure(page)).toBeVisible(); + const frozenWidth = () => + page.evaluate( + () => getComputedStyle(document.getElementById("pipeline-progress-fill")).width, + ); + const first = await frozenWidth(); + await page.waitForTimeout(900); + expect(await frozenWidth()).toBe(first); + }); }); test.describe("Stateful transitions stay isolated per run", () => { @@ -544,6 +585,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"); From 4b568538237534e290fc3f668287746b468a7cf0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:15:59 +0000 Subject: [PATCH 3/4] fix: read rendered width before disabling the transition; freeze as track % MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reading getComputedStyle after transition:none resolves the pending target, not the mid-flight position — capture first, then pin. - The pin is now renderedPx/trackPx as a percentage, so a later viewport resize keeps the same proportion instead of a stale pixel count. - e2e: frozen pin asserts a %-valued inline width and a fixed computed width across a 900ms window. Co-Authored-By: Stuart Gardoll --- app.js | 12 +++++++++--- e2e/stu377-pipeline-progress.spec.js | 8 +++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app.js b/app.js index b8a1272..8adb82e 100644 --- a/app.js +++ b/app.js @@ -8306,10 +8306,16 @@ function renderPipelineTrack() { const fillEl = document.getElementById("pipeline-progress-fill"); if (fillEl) { if (pipelineStageStates[pipelineActiveStage] === "failed") { - // Pin the fill where it visibly is: writing a newer % target would let - // the 700ms width transition keep animating after the run died. + // 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 = getComputedStyle(fillEl).width; + fillEl.style.width = trackPx ? `${(renderedPx / trackPx) * 100}%` : rendered; } else { fillEl.style.transition = ""; fillEl.style.width = `${pipelineTrackPercent()}%`; diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index 65405ef..580d844 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -485,9 +485,15 @@ test.describe("Recoverable failure states", () => { .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 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(/%$/); const frozenWidth = () => page.evaluate( () => getComputedStyle(document.getElementById("pipeline-progress-fill")).width, From 729cd4294a2628882ffcc62a756f8abad8d15353 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:18:06 +0000 Subject: [PATCH 4/4] test: assert the frozen fill keeps its track share across a resize Co-Authored-By: Stuart Gardoll --- e2e/stu377-pipeline-progress.spec.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index 580d844..55d5885 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -501,6 +501,23 @@ test.describe("Recoverable failure states", () => { 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); }); });