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
101 changes: 64 additions & 37 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -989,7 +989,14 @@ async function initializeApiKeys() {

// --- API KEY UI FUNCTIONS ---

function openApiKeysModal() {
// Onboarding-scoped editor re-entry. The walkthrough's own "connect account"
// step sets this before opening the shared API-key editor so that closing it
// returns to (and advances) the tour. Settings opened from any other surface
// (account card, gear, commit flow) stays false, so closing those never
// launches or advances the walkthrough.
let walkthroughSettingsActive = false;

function openApiKeysModalBody() {
const modal = document.getElementById("api-keys-modal");
openModal(modal);

Expand All @@ -1003,17 +1010,47 @@ function openApiKeysModal() {
}
}

function openApiKeysModal() {
// A standalone settings visit, not onboarding: closing it must not touch the
// walkthrough.
walkthroughSettingsActive = false;
openApiKeysModalBody();
}

// Entered by the walkthrough's "Connect your FlutterFlow account" step. Routes
// the user to the real account control and remembers, on this one close, to
// return to the originating tour step.
function walkthroughConnectAccount() {
walkthroughSettingsActive = true;
// Close the tour first so the shared editor never stacks on top of it.
const wt = document.getElementById("walkthrough-modal");
if (wt) closeModal(wt);
openApiKeysModalBody();
}

function closeApiKeysModal(event) {
if (event && event.target !== event.currentTarget) return;
const modal = document.getElementById("api-keys-modal");
if (modal) {
closeModal(modal);
}
// Show walkthrough again after closing API keys
const walkthroughModal = document.getElementById("walkthrough-modal");
if (walkthroughModal) {
advanceWalkthrough();
openModal(walkthroughModal);
const fromWalkthrough = walkthroughSettingsActive;
walkthroughSettingsActive = false;
if (fromWalkthrough) {
// Return to the originating walkthrough step. Advance only when the
// account is genuinely connected, per the STU-384 canonical connection
// state (ffConnectionState), which is set only from a successful
// listProjects outcome — never from the mere presence of stored key bytes.
// A failed or cancelled connection returns to the same connect step so the
// user can retry instead of being pushed forward past an unconnected
// account.
const walkthroughModal = document.getElementById("walkthrough-modal");
if (walkthroughModal) {
if (ffConnectionState === "connected") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unsaved keys advance onboarding

After a valid unsaved key is blurred, ffConnectionState becomes connected and closing the editor advances onboarding. fetchProjects validates the field without storing it, leaving the account unconfigured.

Suggested change
if (ffConnectionState === "connected") {
if (hasStoredKey("flutterflow") && ffConnectionState === "connected") {

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

advanceWalkthrough();
}
openModal(walkthroughModal);
}
}
}

Expand All @@ -1030,38 +1067,17 @@ function updateWalkthroughUI() {
if (!steps.length) return;
steps.forEach((stepEl, idx) => {
const i = idx + 1;
stepEl.classList.remove("wt-current", "wt-done", "wt-pending");
const numEl = stepEl.querySelector(".wt-step-num");
if (i === walkthroughStep) {
stepEl.classList.remove("opacity-60", "bg-gray-50", "border-gray-200");
stepEl.classList.add("bg-blue-50", "border-blue-200");
const numEl = stepEl.querySelector("div:first-child");
if (numEl) {
numEl.classList.remove("bg-gray-400");
numEl.classList.add("bg-blue-500");
numEl.innerHTML = i;
}
stepEl.classList.add("wt-current");
if (numEl) numEl.textContent = i;
} else if (i < walkthroughStep) {
stepEl.classList.remove("opacity-60", "bg-blue-50", "border-blue-200");
stepEl.classList.add("bg-green-50", "border-green-200");
const numEl = stepEl.querySelector("div:first-child");
if (numEl) {
numEl.classList.remove("bg-blue-500", "bg-gray-400");
numEl.classList.add("bg-green-500");
numEl.innerHTML = "✓";
}
stepEl.classList.add("wt-done");
if (numEl) numEl.textContent = "✓";
} else {
stepEl.classList.add("opacity-60", "bg-gray-50", "border-gray-200");
stepEl.classList.remove(
"bg-blue-50",
"border-blue-200",
"bg-green-50",
"border-green-200",
);
const numEl = stepEl.querySelector("div:first-child");
if (numEl) {
numEl.classList.remove("bg-blue-500", "bg-green-500");
numEl.classList.add("bg-gray-400");
numEl.innerHTML = i;
}
stepEl.classList.add("wt-pending");
if (numEl) numEl.textContent = i;
}
});
}
Expand Down Expand Up @@ -1207,10 +1223,11 @@ function updateApiKeyStatusIndicators() {
async function saveApiKeys() {
const flutterflowInput = document.getElementById("flutterflow-api-key-input");
const projectSelect = document.getElementById("flutterflow-projects-select");
const enteredKey = flutterflowInput.value.trim();

// Only save if user entered a new value
if (flutterflowInput.value.trim()) {
await saveApiKey("flutterflow", flutterflowInput.value);
if (enteredKey) {
await saveApiKey("flutterflow", enteredKey);
}

const selectedProjectId = projectSelect?.value.trim() || "";
Expand All @@ -1226,6 +1243,15 @@ async function saveApiKeys() {
// Reinitialize keys
await initializeApiKeys();

// If a key was (re)entered, wait for the REAL connection outcome before the
// editor closes, so the walkthrough advance decision in closeApiKeysModal
// sees a settled canonical state rather than an in-flight "validating" or
// the mere presence of stored bytes. STU-384 owns this state; it is only
// "connected" after a real listProjects returns at least one project.
if (enteredKey) {
await validateFlutterFlowConnection();
}

// Update UI
loadApiKeyInputs();

Expand Down Expand Up @@ -6894,6 +6920,7 @@ window.copyCode = copyCode;
window.retryWithDifferentModel = retryWithDifferentModel;
window.openApiKeysModal = openApiKeysModal;
window.closeApiKeysModal = closeApiKeysModal;
window.walkthroughConnectAccount = walkthroughConnectAccount;
window.closeWalkthroughModal = closeWalkthroughModal;
window.openWalkthroughModal = openWalkthroughModal;
window.advanceWalkthrough = advanceWalkthrough;
Expand Down
47 changes: 41 additions & 6 deletions e2e/stu390-logo-loop.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,37 @@ async function steadyFramesIn(page, ms = 500) {
return after - before;
}

// Measures the logo's painted frames against the number of actual display
// frames over the SAME wall-clock window. A single loop paints at most once per
// display frame (ratio ~1); a leaked second loop paints ~2x per display frame
// (ratio ~2). Because both counts share one window, CPU contention sags them
// together and cannot inflate the ratio — unlike comparing two separate window
// counts, which is how a slower CI runner stalls the baseline and trips a
// `framesB < framesA * c` style assertion. This is the load-robust spelling of
// "the loop resumed without accumulating a second loop."
async function framesPerDisplayFrame(page, ms = 500) {
await page.evaluate(() => {
if (!window.__displayFrames) {
window.__displayFrames = 0;
(function tick() {
window.__displayFrames += 1;
requestAnimationFrame(tick);
})();
}
});
await page.waitForTimeout(250); // let the display counter run at steady rate
const before = await page.evaluate(() => [
window.__pipelineLogo?.frameCount() ?? 0,
window.__displayFrames || 0,
]);
await page.waitForTimeout(ms);
const after = await page.evaluate(() => [
window.__pipelineLogo?.frameCount() ?? 0,
window.__displayFrames || 0,
]);
return { logo: after[0] - before[0], display: after[1] - before[1] };
}

// Bring the pipeline arena onto screen without a backend run. The progress
// panel lives in #generation-stage (hidden until a run begins) inside
// #main-stage-container (opacity 0 unless .visible). showPipelineProgress
Expand Down Expand Up @@ -224,8 +255,6 @@ test.describe("Decorative mark lifecycle in the pipeline arena", () => {
await page.setViewportSize(DESKTOP);
await openHome(page);
await revealPipeline(page);
const framesOn = await steadyFramesIn(page);
expect(framesOn).toBeGreaterThan(5);

await page.emulateMedia({ reducedMotion: "reduce" });
await expect.poll(() => running(page), { timeout: 3000 }).toBe(false);
Expand All @@ -247,12 +276,18 @@ test.describe("Decorative mark lifecycle in the pipeline arena", () => {
expect(pieces.a).toBe(1);
expect(pieces.b).toBe(1);

// Live toggle back on resumes without accumulating loops.
// Live toggle back on resumes without accumulating a second loop. Compare
// the resumed logo frames against display frames in the same window rather
// than against a separate baseline window: the ratio is load-invariant
// (contention slows both counters together), so it cannot trip on a slow CI
// runner while still failing if the loop genuinely doubled (~2x per frame).
await page.emulateMedia({ reducedMotion: "no-preference" });
await expect.poll(() => running(page), { timeout: 3000 }).toBe(true);
const framesResumed = await steadyFramesIn(page);
expect(framesResumed).toBeGreaterThan(5);
expect(framesResumed).toBeLessThan(framesOn * 1.7);
const resumed = await framesPerDisplayFrame(page, 500);
expect(resumed.logo, "the loop should have resumed painting").toBeGreaterThan(5);
expect(resumed.display, "the window should contain real display frames").toBeGreaterThan(0);
// One loop paints at most once per display frame; a doubled loop would ~2x.
expect(resumed.logo).toBeLessThan(resumed.display * 1.6);
});
});

Expand Down
Loading
Loading