From 82a1f8dca0453fd5d28acc5990c41e09a122bb54 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:12:11 +0000 Subject: [PATCH 1/3] fix: hero demo takeover can never submit a partial prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mid-demo click/keypress canceled the typing timer but left the frozen prefix in the field, so a Generate click or Enter could submit a half-typed example. Takeover now lands the demo's own end state — the complete shipped prompt with the caret retired — via finishHeroDemo. Two takeover shapes skip the restore instead: an interaction arriving with text already selected (select-all, 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 has nothing typed to complete. Both freeze the demo on the prefix and mark it demo-owned: send stays disabled and Enter stays inert until a real input event hands the text to the user. Also fixes the reduced-motion listener lifecycle: a pagehide dispose made bfcache restores lose the listener, so a demo replayed after back-forward navigation ignored a later flip. The listener now lives for the document's lifetime (dispose reserved for genuine teardown), and a repeat initComposer on the same DOM replaces the dead registration via a WeakMap instead of stacking a second listener. Adds an e2e covering a mid-demo click followed by an immediate submit, and demo-completion waits in smoke/stu376 where prompts were being polled mid-type. Co-Authored-By: Stuart Gardoll --- e2e/smoke.spec.js | 7 ++- e2e/stu376-composer.spec.js | 52 ++++++++++++++++++++++ src/composerAdapter.js | 88 +++++++++++++++++++++++++++++-------- 3 files changed, 128 insertions(+), 19 deletions(-) diff --git a/e2e/smoke.spec.js b/e2e/smoke.spec.js index 14a8964..1f23aae 100644 --- a/e2e/smoke.spec.js +++ b/e2e/smoke.spec.js @@ -56,9 +56,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 a4ac78f..545b16d 100644 --- a/e2e/stu376-composer.spec.js +++ b/e2e/stu376-composer.spec.js @@ -77,6 +77,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(); @@ -410,6 +416,52 @@ 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("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 c2a4e2c..e915cd2 100644 --- a/src/composerAdapter.js +++ b/src/composerAdapter.js @@ -41,6 +41,11 @@ export const SUGGESTION_MIN_CHARS = 6; const HERO_DEMO_HOLD_MS = 600; const HERO_DEMO_TYPE_MS = 45; +// 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,10 @@ 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; let attachmentsPending = false; // image uploads in flight own the run's attachments let debounceTimer = null; let chipTimer = null; @@ -140,7 +149,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. @@ -293,12 +302,39 @@ 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. + function finishHeroDemo() { + if (!heroDemoTyping) return; + if (!field.value.length || field.selectionStart !== field.selectionEnd) { + heroDemoFrozen = true; + cancelHeroDemo(); + return; + } + field.value = shippedPrompt; + mirrorTyped(); + heroDemoFrozen = 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 || !heroDemoTyping) return; + if (!reduceMotion.matches) return; + if (!heroDemoTyping) return; field.value = shippedPrompt; mirrorTyped(); cancelHeroDemo(); @@ -335,6 +371,7 @@ export function initComposer({ onSubmit }) { } function fillChip(chip) { + heroDemoFrozen = false; const text = chip.dataset.prompt || ""; cancelHeroDemo(); cancelChipTyping(); @@ -386,10 +423,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 && @@ -398,7 +436,7 @@ export function initComposer({ onSubmit }) { event.preventDefault(); return; } - cancelHeroDemo(); + finishHeroDemo(); cancelChipTyping(); if (event.key === "Tab" && !event.shiftKey && session.active) { event.preventDefault(); @@ -420,6 +458,7 @@ export function initComposer({ onSubmit }) { // --- Input (prototype input handler, line 1800) --- field.addEventListener("input", () => { + heroDemoFrozen = false; cancelHeroDemo(); cancelChipTyping(); clearChipSelection(); @@ -455,29 +494,42 @@ 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. field.addEventListener("pointerdown", () => { - cancelHeroDemo(); + finishHeroDemo(); 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() { reduceMotion.removeEventListener("change", onReducedMotionChange); + demoMotionRegistrations.delete(composer); } - 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(); @@ -494,7 +546,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, }; } From 790dd488b30d892da86f8e0b6bf1b6d26e56c82f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:27:51 +0000 Subject: [PATCH 2/3] fix: edits while the demo owns the field never join the example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takeover restore landed the full shippedPrompt in the field, but a keystroke, Backspace, paste, drop or IME commit that followed then joined the example — the browser inserts into whatever the field holds, and the input handler treated the combination as user text. Text the demo supplied while interrupted is now tracked as demo-owned (heroDemoOwned) through the frozen and restored states. A beforeinput listener — the single funnel every real edit passes through — clears it before the native mutation lands, so typing mid-demo produces a prompt of the user's own and Backspace removes demo text rather than deleting inside the example. A demo that finishes untouched hands the shipped text back as the field's own content, so ordinary editing resumes. New e2e coverage: mid-demo typing yields only the typed text, and Backspace during the demo leaves an empty field. Co-Authored-By: Stuart Gardoll --- e2e/stu376-composer.spec.js | 60 +++++++++++++++++++++++++++++++++++++ src/composerAdapter.js | 28 ++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/e2e/stu376-composer.spec.js b/e2e/stu376-composer.spec.js index 545b16d..3c8be57 100644 --- a/e2e/stu376-composer.spec.js +++ b/e2e/stu376-composer.spec.js @@ -462,6 +462,66 @@ test.describe("STU-445 hero composer behaviour", () => { 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 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 e915cd2..265599e 100644 --- a/src/composerAdapter.js +++ b/src/composerAdapter.js @@ -140,6 +140,12 @@ export function initComposer({ onSubmit }) { // 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; @@ -280,6 +286,7 @@ export function initComposer({ onSubmit }) { const hash = window.location.hash.replace(/^#/, ""); if (hash && hash !== "home") return; heroDemoTyping = true; + heroDemoOwned = true; field.value = ""; mirrorTyped(); syncSend(); @@ -292,7 +299,9 @@ export function initComposer({ onSubmit }) { mirrorTyped(); if (index >= shippedPrompt.length) { // The prompt is fully written: retire the caret and hand the field - // back exactly as the markup shipped it. + // back exactly as the markup shipped it — as the field's own + // content, editable in place like the shipped default. + heroDemoOwned = false; cancelHeroDemo(); } else { syncSend(); @@ -337,6 +346,7 @@ export function initComposer({ onSubmit }) { if (!heroDemoTyping) return; field.value = shippedPrompt; mirrorTyped(); + heroDemoOwned = false; cancelHeroDemo(); } @@ -372,6 +382,7 @@ export function initComposer({ onSubmit }) { function fillChip(chip) { heroDemoFrozen = false; + heroDemoOwned = false; const text = chip.dataset.prompt || ""; cancelHeroDemo(); cancelChipTyping(); @@ -456,8 +467,23 @@ 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(); From d0416105e242602948eedb6095f6cd87d4c782b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:13:43 +0000 Subject: [PATCH 3/3] fix: system cancellations and pointer takeovers settle the hero demo - cancelTyping() lands each running demo's end state instead of abandoning a submittable prefix (view switches, tab hides). - A pointerdown takeover releases demo ownership after the restore, so the first Backspace trims the complete prompt; focus/keydown restores stay demo-owned so typing mid-demo still writes a fresh prompt. - e2e: click+Backspace trims one char; switchView during typing and during the hold both settle the shipped prompt and Generate submits it whole. Co-Authored-By: Stuart Gardoll --- e2e/stu376-composer.spec.js | 105 ++++++++++++++++++++++++++++++++++++ src/composerAdapter.js | 34 +++++++++--- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/e2e/stu376-composer.spec.js b/e2e/stu376-composer.spec.js index c229100..2a42c73 100644 --- a/e2e/stu376-composer.spec.js +++ b/e2e/stu376-composer.spec.js @@ -524,6 +524,111 @@ test.describe("STU-445 hero composer behaviour", () => { 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 e2f6a94..3ffd050 100644 --- a/src/composerAdapter.js +++ b/src/composerAdapter.js @@ -346,7 +346,13 @@ export function initComposer({ onSubmit }) { // 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. - function finishHeroDemo() { + // 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. @@ -359,6 +365,7 @@ export function initComposer({ onSubmit }) { field.value = shippedPrompt; mirrorTyped(); heroDemoFrozen = false; + if (releaseOwned) heroDemoOwned = false; cancelHeroDemo(); } @@ -554,14 +561,16 @@ export function initComposer({ onSubmit }) { // 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. + // 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", () => { - finishHeroDemo(); + finishHeroDemo(true); cancelChipTyping(); clearChipSelection(); }); - field.addEventListener("focus", finishHeroDemo); + field.addEventListener("focus", () => finishHeroDemo()); // The reduced-motion preference can change while the page is open; the // listener lives for the document's lifetime — including a trip through the @@ -590,8 +599,21 @@ export function initComposer({ onSubmit }) { } 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); }