diff --git a/e2e/smoke.spec.js b/e2e/smoke.spec.js index d30f03d..c2da75c 100644 --- a/e2e/smoke.spec.js +++ b/e2e/smoke.spec.js @@ -59,9 +59,14 @@ test.describe("Redesigned hero landing and composer", () => { await expect(heading).toBeVisible(); await expect(heading).toContainText("FlutterFlow custom code."); - // Composer is present with its shipped example prompt. + // Composer is present with its shipped example prompt. The hero demo + // types it in on load, so wait for the demo's end state rather than + // polling the prefix. const composer = page.locator("#pipeline-input"); await expect(composer).toBeVisible(); + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/, { + timeout: 15000, + }); await expect(composer).toHaveValue(COMPOSER_DEFAULT_PROMPT); await expect(composer).toHaveAttribute( "placeholder", diff --git a/e2e/stu376-composer.spec.js b/e2e/stu376-composer.spec.js index b0e51cf..2a42c73 100644 --- a/e2e/stu376-composer.spec.js +++ b/e2e/stu376-composer.spec.js @@ -78,6 +78,12 @@ for (const { name, width, height } of viewports) { await page.goto("/"); const field = page.locator("#pipeline-input"); + // The hero demo types the shipped prompt in on load — the settled value + // only holds once the class retires, so wait for the demo's end state + // rather than polling the prefix. + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/, { + timeout: 15000, + }); await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT); await expect(page.locator(".ghost .suggest")).toHaveText(""); await expect(page.locator("#tab-hint")).toBeHidden(); @@ -412,6 +418,217 @@ test.describe("STU-445 hero composer behaviour", () => { await expect(page.locator("#hero-send")).toBeEnabled(); }); + test("a mid-demo click completes the prompt — a submit can never carry a prefix", async ({ + page, + }) => { + const prompts = []; + await page.route(ENDPOINTS.pipeline, async (route) => { + const body = route.request().postDataJSON(); + if (body && body.step === "architect") prompts.push(body.prompt); + await route.fulfill(oneArtifact()); + }); + + await page.goto("/", { waitUntil: "commit" }); + + // Catch the demo mid-type: a real prefix is in the field and the rest of + // the prompt is still seconds away, so a frozen prefix would fail below. + await page.waitForFunction( + (expected) => { + const field = document.getElementById("pipeline-input"); + const composer = document.getElementById("composer"); + return ( + composer?.classList.contains("is-demo-typing") && + field && + field.value.length > 0 && + field.value.length < expected.length + ); + }, + COMPOSER_DEFAULT_PROMPT, + { timeout: 8000 }, + ); + + // The takeover lands the complete shipped prompt — the same end state as + // a finished demo — not whatever the timer had reached. + await page.locator("#pipeline-input").click(); + const field = page.locator("#pipeline-input"); + await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT); + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/); + await expect(page.locator("#hero-send")).toBeEnabled(); + + // Submitting right after the takeover runs the complete example, never a + // half-typed prefix. + await page.locator("#hero-send").click(); + await expect + .poll(() => prompts.length, { timeout: 30000 }) + .toBe(1); + expect(prompts[0]).toContain(COMPOSER_DEFAULT_PROMPT); + }); + + test("mid-demo typing writes a new prompt — it never joins the example", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "commit" }); + + // Catch the demo mid-type: a real prefix is in the field and the rest of + // the prompt is still seconds away. + await page.waitForFunction( + (expected) => { + const field = document.getElementById("pipeline-input"); + const composer = document.getElementById("composer"); + return ( + composer?.classList.contains("is-demo-typing") && + field && + field.value.length > 0 && + field.value.length < expected.length + ); + }, + COMPOSER_DEFAULT_PROMPT, + { timeout: 8000 }, + ); + + // The demo's text is demo-owned, not a draft: the first real edit clears + // it, so the keystrokes produce a prompt of the user's own — never the + // shipped example with new text joined to it. + const field = page.locator("#pipeline-input"); + await field.pressSequentially("A velocity gauge"); + await expect(field).toHaveValue("A velocity gauge"); + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/); + await expect(page.locator("#hero-send")).toBeEnabled(); + }); + + test("Backspace during the demo clears the demo text, not the whole example", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "commit" }); + + await page.waitForFunction( + (expected) => { + const field = document.getElementById("pipeline-input"); + const composer = document.getElementById("composer"); + return ( + composer?.classList.contains("is-demo-typing") && + field && + field.value.length > 0 && + field.value.length < expected.length + ); + }, + COMPOSER_DEFAULT_PROMPT, + { timeout: 8000 }, + ); + + // Deleting into a demo-owned field removes the demo's text: the native + // delete sees the cleared field, not a restored example to delete inside. + const field = page.locator("#pipeline-input"); + await field.press("Backspace"); + await expect(field).toHaveValue(""); + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/); + }); + + test("a mid-demo click hands the prompt over — Backspace trims it", async ({ page }) => { + await page.goto("/", { waitUntil: "commit" }); + + // Catch the demo mid-type: a real prefix is in the field and the rest of + // the prompt is still seconds away. + await page.waitForFunction( + (expected) => { + const field = document.getElementById("pipeline-input"); + const composer = document.getElementById("composer"); + return ( + composer?.classList.contains("is-demo-typing") && + field && + field.value.length > 0 && + field.value.length < expected.length + ); + }, + COMPOSER_DEFAULT_PROMPT, + { timeout: 8000 }, + ); + + // A pointer takeover means "edit what I clicked": the restore lands the + // complete prompt as the field's own text, so a later Backspace trims a + // single character instead of clearing the demo-owned example. + const field = page.locator("#pipeline-input"); + await field.click(); + await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT); + await field.press("End"); + await field.press("Backspace"); + await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT.slice(0, -1)); + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/); + await expect(page.locator("#hero-send")).toBeEnabled(); + }); + + test("leaving Home mid-demo settles the prompt — never a submittable prefix", async ({ + page, + }) => { + const prompts = []; + await page.route(ENDPOINTS.pipeline, async (route) => { + const body = route.request().postDataJSON(); + if (body && body.step === "architect") prompts.push(body.prompt); + await route.fulfill(oneArtifact()); + }); + + await page.goto("/", { waitUntil: "commit" }); + + // Catch the demo mid-type: a real prefix is in the field and the rest of + // the prompt is still seconds away, so a frozen prefix would fail below. + await page.waitForFunction( + (expected) => { + const field = document.getElementById("pipeline-input"); + const composer = document.getElementById("composer"); + return ( + composer?.classList.contains("is-demo-typing") && + field && + field.value.length > 0 && + field.value.length < expected.length + ); + }, + COMPOSER_DEFAULT_PROMPT, + { timeout: 8000 }, + ); + + // Switching surfaces is a system cancellation, not a user takeover: the + // demo settles on the complete shipped prompt, so a returning Generate + // can never carry whatever prefix the timer reached. + await page.evaluate(() => window.switchView("account")); + await page.evaluate(() => window.switchView("home")); + const field = page.locator("#pipeline-input"); + await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT); + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/); + await expect(page.locator("#hero-send")).toBeEnabled(); + + await page.locator("#hero-send").click(); + await expect + .poll(() => prompts.length, { timeout: 30000 }) + .toBe(1); + expect(prompts[0]).toContain(COMPOSER_DEFAULT_PROMPT); + }); + + test("leaving Home during the demo hold settles the prompt too", async ({ page }) => { + await page.goto("/", { waitUntil: "commit" }); + + // Catch the demo inside its hold: the typing class is on but the first + // character has not landed yet. + await page.waitForFunction( + () => { + const field = document.getElementById("pipeline-input"); + const composer = document.getElementById("composer"); + return ( + composer?.classList.contains("is-demo-typing") && + field && + field.value === "" + ); + }, + undefined, + { timeout: 8000 }, + ); + + await page.evaluate(() => window.switchView("account")); + await page.evaluate(() => window.switchView("home")); + await expect(page.locator("#pipeline-input")).toHaveValue(COMPOSER_DEFAULT_PROMPT); + await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/); + await expect(page.locator("#hero-send")).toBeEnabled(); + }); + test("a live reduced-motion flip mid-demo stops the typing and restores the prompt", async ({ page, }) => { diff --git a/src/composerAdapter.js b/src/composerAdapter.js index 67874c4..3ffd050 100644 --- a/src/composerAdapter.js +++ b/src/composerAdapter.js @@ -41,6 +41,11 @@ export const SUGGESTION_MIN_CHARS = 6; const HERO_DEMO_HOLD_MS = 5200; const HERO_DEMO_TYPE_MS = 73; +// The hero demo's media-query listener is tracked per composer element so a +// repeat initComposer on the same DOM replaces the previous instance's +// registration instead of stacking a second listener on it. +const demoMotionRegistrations = new WeakMap(); + /** A prompt can only submit when it has non-whitespace text and the pipeline is idle. */ export function canSubmit(value, isBusy) { return Boolean(value && value.trim()) && !isBusy; @@ -131,6 +136,16 @@ export function initComposer({ onSubmit }) { let submitting = false; // duplicate-submission guard let chipTyping = false; // a chip still typing its prompt owns the field let heroDemoTyping = false; // the opening demo still typing the shipped prompt + // The demo stopped on a frozen prefix a pending edit will replace. Until a + // real input event lands, that prefix is demo-owned, not user text: it must + // gate the send control and Enter exactly like a still-running demo. + let heroDemoFrozen = false; + // Text the demo supplied while it was interrupted: a frozen prefix, or the + // complete example a non-editing takeover restored. The first real + // beforeinput clears it, so a new prompt never silently joins the shipped + // example. A demo that finishes untouched hands the shipped text back as + // the field's own content, and ordinary editing resumes. + let heroDemoOwned = false; let attachmentsPending = false; // image uploads in flight own the run's attachments let debounceTimer = null; let chipTimer = null; @@ -140,7 +155,7 @@ export function initComposer({ onSubmit }) { const shippedPrompt = field.value; function isBusy() { - return submitting || chipTyping || heroDemoTyping || attachmentsPending; + return submitting || chipTyping || heroDemoTyping || heroDemoFrozen || attachmentsPending; } // Prototype sync(): the send control follows the prompt and the pipeline. @@ -273,6 +288,7 @@ export function initComposer({ onSubmit }) { const hash = window.location.hash.replace(/^#/, ""); if (hash && hash !== "home") return; heroDemoTyping = true; + heroDemoOwned = true; field.placeholder = ""; field.value = ""; mirrorTyped(); @@ -285,12 +301,14 @@ export function initComposer({ onSubmit }) { field.value = shippedPrompt.slice(0, index); mirrorTyped(); if (index >= shippedPrompt.length) { - // The prompt is fully written: retire the caret and hand the field - // back exactly as the markup shipped it. + // The prompt is fully written: retire the typing caret and let the + // demo stream its follow-on suggestion, then hand the field back + // exactly as the markup shipped it — as the field's own content, + // editable in place like the shipped default. composer.classList.remove("is-demo-typing"); heroDemoTimer = setTimeout(() => { const suffix = session.resolve(field.value); - if (!suffix) { cancelHeroDemo(); return; } + if (!suffix) { heroDemoOwned = false; cancelHeroDemo(); return; } showSuggestionUi(suffix); composer.classList.add("is-demo-streaming"); suggest.textContent = ""; @@ -303,7 +321,7 @@ export function initComposer({ onSubmit }) { suggest.appendChild(span); span.animate?.([{ opacity:0 }, { opacity:1 }], { duration:240, easing:"cubic-bezier(0.4,0,0.2,1)" }); if (word < words.length) heroDemoTimer = setTimeout(stream, 130); - else cancelHeroDemo(); + else { heroDemoOwned = false; cancelHeroDemo(); } }; stream(); }, 1000); @@ -315,15 +333,52 @@ export function initComposer({ onSubmit }) { heroDemoTimer = setTimeout(typeNext, HERO_DEMO_HOLD_MS); } + // End a running demo early by landing its end state — the complete shipped + // prompt, the caret retired, the send control synced — rather than freezing + // on whatever prefix the timer reached. A submit after a non-editing + // takeover can therefore never carry a half-typed prompt, and text the user + // already owns (heroDemoTyping false) is never overwritten. + // + // Two takeover shapes skip the restore: an interaction arriving with text + // already selected (a select-all fill, an assistive insert) is an edit in + // flight — overwriting the field would destroy the selection the incoming + // replacement needs — and a takeover during the hold period, before the + // first typed character lands, has nothing to complete. Both stop the demo + // on the frozen prefix and mark it demo-owned: send stays disabled and + // Enter stays inert until a real input event hands the text to the user. + // A pointer takeover means "edit what I clicked": after the restore the + // complete prompt is plain field content, so ownership is released and the + // first edit trims it character by character. Keyboard-only takeovers + // (focus, a keypress before any click) keep the text demo-owned instead — + // typing mid-demo means "write a new prompt", so the first beforeinput + // still clears the example rather than joining it. + function finishHeroDemo(releaseOwned = false) { + if (!heroDemoTyping) return; + // The demo's streamed suggestion is demo-owned too: a takeover drops the + // ghost along with the writer, whatever branch below it takes. + clearSuggestion(); + if (!field.value.length || field.selectionStart !== field.selectionEnd) { + heroDemoFrozen = true; + cancelHeroDemo(); + return; + } + field.value = shippedPrompt; + mirrorTyped(); + heroDemoFrozen = false; + if (releaseOwned) heroDemoOwned = false; + cancelHeroDemo(); + } + // A live flip to reduced motion must end the demo, not just keep it from // starting: cancel the timer and land the field on the complete shipped // prompt with the caret retired — the same end state as a finished demo. - // Text the user has taken over (heroDemoTyping already false) is untouched. + // Not a user takeover, so an in-flight selection doesn't veto the restore. function onReducedMotionChange() { if (!reduceMotion.matches) return; if (heroDemoTyping) { field.value = shippedPrompt; clearSuggestion(); + heroDemoOwned = false; cancelHeroDemo(); } if (chipTyping) { @@ -364,6 +419,8 @@ export function initComposer({ onSubmit }) { } function fillChip(chip) { + heroDemoFrozen = false; + heroDemoOwned = false; const text = chip.dataset.prompt || ""; cancelHeroDemo(); cancelChipTyping(); @@ -415,10 +472,11 @@ export function initComposer({ onSubmit }) { // Tab accepts only an active suggestion and otherwise moves focus; Escape // dismisses; Enter submits unless Shift is held or an IME is composing. field.addEventListener("keydown", (event) => { - // Enter while the hero demo or a chip is still typing is not a submission - // and not a cancellation: the writer finishes the full prompt first. + // Enter while the hero demo or a chip is still typing — or while a frozen + // demo prefix still waits on the edit that was promised — is not a + // submission and not a cancellation: the writer finishes first. if ( - (heroDemoTyping || chipTyping) && + (heroDemoTyping || chipTyping || heroDemoFrozen) && event.key === "Enter" && !event.shiftKey && !event.isComposing && @@ -427,7 +485,7 @@ export function initComposer({ onSubmit }) { event.preventDefault(); return; } - cancelHeroDemo(); + finishHeroDemo(); cancelChipTyping(); if (event.key === "Tab" && !event.shiftKey && session.active) { event.preventDefault(); @@ -447,8 +505,24 @@ export function initComposer({ onSubmit }) { } }); + // Demo-owned text is never a draft: any real edit — typed characters, + // Backspace, a paste, a drag-dropped snippet, an IME commit — clears it + // first, so the user's input lands on an empty field instead of joining + // the shipped example. beforeinput sees every vector the browser will + // mutate for; the demo's own programmatic writes never reach it. + field.addEventListener("beforeinput", () => { + if (!heroDemoOwned) return; + heroDemoOwned = false; + heroDemoFrozen = false; + field.value = ""; + mirrorTyped(); + cancelHeroDemo(); + }); + // --- Input (prototype input handler, line 1800) --- field.addEventListener("input", () => { + heroDemoOwned = false; + heroDemoFrozen = false; cancelHeroDemo(); cancelChipTyping(); clearChipSelection(); @@ -484,41 +558,68 @@ export function initComposer({ onSubmit }) { send.addEventListener("click", doSubmit); - // The user taking the field back ends the chip's settled state and stops a - // running hero demo, leaving whatever it had typed for the user to own. + // The user taking the field back ends the chip's settled state. A running + // hero demo completes instead of freezing mid-type, so Generate after a + // bare click, focus or keypress submits the whole example — the same end + // state as a demo that finished on its own. A pointerdown also releases + // demo ownership: the user clicked to edit, so the restored prompt is + // theirs to trim. field.addEventListener("pointerdown", () => { - cancelHeroDemo(); + finishHeroDemo(true); cancelChipTyping(); clearChipSelection(); }); - field.addEventListener("focus", () => { - cancelHeroDemo(); - }); + field.addEventListener("focus", () => finishHeroDemo()); // The reduced-motion preference can change while the page is open; the - // listener is registered exactly once here (never per demo start) and is - // removed by dispose, so a torn-down composer stops reacting to it. + // listener lives for the document's lifetime — including a trip through the + // back-forward cache, where no real teardown happens — so a resumed demo + // still sees a later flip. dispose() removes it for a genuine composer + // teardown only. A repeat initComposer on the same DOM replaces the dead + // instance's registration instead of stacking a second listener. + const previousMotionRegistration = demoMotionRegistrations.get(composer); + if (previousMotionRegistration) { + previousMotionRegistration.mql.removeEventListener( + "change", + previousMotionRegistration.handler, + ); + } reduceMotion.addEventListener("change", onReducedMotionChange); + demoMotionRegistrations.set(composer, { + mql: reduceMotion, + handler: onReducedMotionChange, + }); function dispose() { cancelTyping(); reduceMotion.removeEventListener("change", onReducedMotionChange); + demoMotionRegistrations.delete(composer); document.removeEventListener("visibilitychange", onVisibilityChange); - window.removeEventListener("pagehide", dispose); } function cancelTyping() { - cancelHeroDemo(); - cancelChipTyping(); + // A system cancellation — switching surfaces or the tab hiding — is not a + // user takeover: land each demo's end state so a half-typed prefix can + // never sit submittable in the field when the user returns. + if (heroDemoTyping) { + field.value = shippedPrompt; + mirrorTyped(); + clearSuggestion(); + heroDemoOwned = false; + cancelHeroDemo(); + } + if (chipTyping) { + field.value = chips.find((chip) => chip.classList.contains("is-active"))?.dataset.prompt || field.value; + mirrorTyped(); + cancelChipTyping(); + } clearTimeout(debounceTimer); } function onVisibilityChange() { if (document.hidden) cancelTyping(); } document.addEventListener("visibilitychange", onVisibilityChange); - window.addEventListener("pagehide", dispose); - // Initial state mirrors the shipped example prompt; the send control // reflects it. The shipped default value stays exactly as authored. mirrorTyped(); @@ -535,7 +636,7 @@ export function initComposer({ onSubmit }) { attachmentsPending = Boolean(pending); syncSend(); }, - // Removes the reduced-motion listener; also runs on pagehide. + // Removes the reduced-motion listener; for a genuine composer teardown. dispose, cancelTyping, };