From cd504c8babb8a43c89d9db04e3e0e2860d87ba90 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 5 Nov 2026 14:40:00 +0330 Subject: [PATCH] Clear a stale Prompt required error once the prompt has text Pressing Generate with an empty prompt sets the status bar to "Prompt required." with the Error pill and shows "Enter a prompt before generating." under it. Nothing took either down until the next Generate, so loading a prompt from the Wallet left a full prompt field sitting above an error saying there was none (G4 smoke run 2026-11-05, finding 4). Typing had the same problem. A small input listener on each tool's prompt field now resets the bar to "Ready." and hides the error line when the status still reads "Prompt required." and the field has text. Every way a prompt arrives already fires "input" (typing, Wallet Load, the agent bridge, prompt-memory undo), so the Wallet itself does not change. It never touches any other error: the status text has to be exactly the prompt-required one. It covers the seven tools that stop with that status, Inpaint included (through its readiness gate). App.ts now writes the status through one exported constant, and a test fails if a tool starts writing it without being registered for the reset. Co-Authored-By: Claude Opus 5.5 --- src/ui/App.ts | 14 +- src/ui/promptRequiredReset.ts | 120 ++++++++++++++ tests/ui/promptRequiredReset.test.ts | 229 +++++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 src/ui/promptRequiredReset.ts create mode 100644 tests/ui/promptRequiredReset.test.ts diff --git a/src/ui/App.ts b/src/ui/App.ts index 24e2607..6dfdd04 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -230,6 +230,7 @@ import { shouldRefreshToolModels } from "./setupInventoryChange"; import { getComfyCheckDiagnostics, getComfyFoundStatus, getComfyOnlineStatus } from "./comfyCheckStatus"; import { bindPromptMemory } from "./promptMemory"; import { bindPromptWallet, PROMPT_WALLET_TOOLS, PromptWalletTool } from "./promptWallet"; +import { bindPromptRequiredReset, PROMPT_REQUIRED_STATUS } from "./promptRequiredReset"; import { createUpscaleResizePlan, formatUpscaleScale, @@ -2121,6 +2122,7 @@ export function renderApp(rootElement: HTMLElement) { bindAdvancedToggles(rootElement); bindPromptMemory(elements); const promptWallet = bindPromptWallet(elements, PROMPT_WALLET_TOOLS, setView); + bindPromptRequiredReset(elements); bindWelcomeOverlay(elements); bindToolWarnings(rootElement); bindStickyProgress(rootElement); @@ -3448,7 +3450,7 @@ export function renderApp(rootElement: HTMLElement) { if (!readTextareaValue(elements.prompt).trim()) { setTextToImageError(elements, getErrorMessage(createOpenLayerError("PROMPT_REQUIRED", "Enter a prompt before generating."))); - setTextToImageStatus(elements, "Prompt required.", "error"); + setTextToImageStatus(elements, PROMPT_REQUIRED_STATUS, "error"); return; } @@ -3989,7 +3991,7 @@ export function renderApp(rootElement: HTMLElement) { if (!readTextareaValue(elements.imgPrompt).trim()) { setImageError(elements, getErrorMessage(createOpenLayerError("PROMPT_REQUIRED", "Enter a prompt before generating."))); - setImageStatus(elements, "Prompt required.", "error"); + setImageStatus(elements, PROMPT_REQUIRED_STATUS, "error"); return; } @@ -5447,7 +5449,7 @@ export function renderApp(rootElement: HTMLElement) { elements, getErrorMessage(createOpenLayerError("PROMPT_REQUIRED", "Enter a prompt before generating Outpaint.")) ); - setOutpaintStatus(elements, "Prompt required.", "error"); + setOutpaintStatus(elements, PROMPT_REQUIRED_STATUS, "error"); return; } @@ -5790,7 +5792,7 @@ export function renderApp(rootElement: HTMLElement) { elements, getErrorMessage(createOpenLayerError("PROMPT_REQUIRED", "Enter a prompt before generating Sketch to Image.")) ); - setSketchStatus(elements, "Prompt required.", "error"); + setSketchStatus(elements, PROMPT_REQUIRED_STATUS, "error"); return; } @@ -6061,7 +6063,7 @@ export function renderApp(rootElement: HTMLElement) { elements, getErrorMessage(createOpenLayerError("PROMPT_REQUIRED", "Enter a prompt before generating Style Reference.")) ); - setStyleReferenceStatus(elements, "Prompt required.", "error"); + setStyleReferenceStatus(elements, PROMPT_REQUIRED_STATUS, "error"); return; } @@ -6487,7 +6489,7 @@ export function renderApp(rootElement: HTMLElement) { elements, getErrorMessage(createOpenLayerError("PROMPT_REQUIRED", "Describe the picture these layers should become.")) ); - setMultiReferenceStatus(elements, "Prompt required.", "error"); + setMultiReferenceStatus(elements, PROMPT_REQUIRED_STATUS, "error"); return; } diff --git a/src/ui/promptRequiredReset.ts b/src/ui/promptRequiredReset.ts new file mode 100644 index 0000000..1baec86 --- /dev/null +++ b/src/ui/promptRequiredReset.ts @@ -0,0 +1,120 @@ +import { AppElements } from "./appMarkup"; +import { + StatusTone, + setImageError, + setImageStatus, + setInpaintError, + setInpaintStatus, + setMultiReferenceError, + setMultiReferenceStatus, + setOutpaintError, + setOutpaintStatus, + setSketchError, + setSketchStatus, + setStyleReferenceError, + setStyleReferenceStatus, + setTextToImageError, + setTextToImageStatus +} from "./statusBars"; + +/** + * Clears a tool's "Prompt required." error as soon as its prompt field has + * text again. + * + * Pressing Generate with an empty prompt sets two things: the status bar reads + * "Prompt required." with the red Error pill, and the error line under it says + * "Enter a prompt before generating." Nothing took them back down until the + * next Generate. So loading a prompt from the Wallet left the screen showing a + * full prompt field directly above an error saying there was none (G4 smoke + * run, 2026-11-05, finding 4). Typing had the same problem; the Wallet just + * made it obvious, because Load sends the artist away and back. + * + * It listens on the field's "input" event rather than inside the Wallet, + * because every way a prompt arrives already fires one: typing, Wallet Load + * (`promptWallet.ts` `useEntry`), the agent bridge (`notifyFieldChanged`) and + * prompt-memory undo. One listener covers them all, and the Wallet stays + * ignorant of status bars. + * + * It only ever clears the prompt error. The status text is the evidence: the + * handlers write exactly PROMPT_REQUIRED_STATUS in that branch and nowhere + * else, and any later run, failure or global message overwrites it. So a + * "Generation failed." or "ComfyUI is offline." is never touched by typing. + */ + +/** What every tool's status bar reads when Generate was pressed with no prompt. */ +export const PROMPT_REQUIRED_STATUS = "Prompt required."; + +/** The status a tool shows at startup, and returns to once the error is stale. */ +const READY_STATUS = "Ready."; + +/** + * True when the status bar still says "Prompt required." but the field now + * holds a prompt, i.e. the error describes a state that no longer exists. + * + * Takes nullable strings on purpose: UXP reports an empty textarea's `.value` + * as null, where browsers and jsdom report "" (see `textareaValue.ts`). + */ +export function isPromptRequiredErrorStale( + statusText: string | null | undefined, + promptText: string | null | undefined +): boolean { + return (statusText ?? "").trim() === PROMPT_REQUIRED_STATUS && (promptText ?? "").trim() !== ""; +} + +export type PromptRequiredTool = { + /** The prompt field Generate checks. */ + prompt: keyof AppElements; + /** The status text that shows "Prompt required.". */ + statusText: keyof AppElements; + setStatus: (elements: AppElements, status: string, tone: StatusTone) => void; + setError: (elements: AppElements, message: string) => void; +}; + +// Every tool whose Generate stops with "Prompt required.". Inpaint reaches it +// through `inpaintReadiness.ts` ("prompt-missing") rather than inline, but it +// lands on the same status text. Live Painting, Unflatten and Prompt from Layer +// have Wallet dots but no prompt-required stop, so they are not listed. +export const PROMPT_REQUIRED_TOOLS: readonly PromptRequiredTool[] = [ + { prompt: "prompt", statusText: "statusText", setStatus: setTextToImageStatus, setError: setTextToImageError }, + { prompt: "imgPrompt", statusText: "imgStatusText", setStatus: setImageStatus, setError: setImageError }, + { prompt: "sketchPrompt", statusText: "sketchStatusText", setStatus: setSketchStatus, setError: setSketchError }, + { prompt: "inpaintPrompt", statusText: "inpaintStatusText", setStatus: setInpaintStatus, setError: setInpaintError }, + { prompt: "outpaintPrompt", statusText: "outpaintStatusText", setStatus: setOutpaintStatus, setError: setOutpaintError }, + { + prompt: "styleReferencePrompt", + statusText: "styleReferenceStatusText", + setStatus: setStyleReferenceStatus, + setError: setStyleReferenceError + }, + { + prompt: "multiReferencePrompt", + statusText: "multiReferenceStatusText", + setStatus: setMultiReferenceStatus, + setError: setMultiReferenceError + } +]; + +/** + * Never allowed to break the panel: a throw here lands partway through + * renderApp and silently disables every binding registered after it. + */ +export function bindPromptRequiredReset( + elements: AppElements, + tools: readonly PromptRequiredTool[] = PROMPT_REQUIRED_TOOLS +) { + try { + for (const tool of tools) { + const field = elements[tool.prompt] as HTMLTextAreaElement; + const status = elements[tool.statusText] as HTMLElement; + + field.addEventListener("input", () => { + if (isPromptRequiredErrorStale(status.textContent, field.value)) { + tool.setError(elements, ""); + tool.setStatus(elements, READY_STATUS, "idle"); + } + }); + } + } catch (error) { + console.log("[OpenLayer] prompt-required reset failed to bind:", error); + } +} diff --git a/tests/ui/promptRequiredReset.test.ts b/tests/ui/promptRequiredReset.test.ts new file mode 100644 index 0000000..4576a86 --- /dev/null +++ b/tests/ui/promptRequiredReset.test.ts @@ -0,0 +1,229 @@ +// @vitest-environment jsdom +// +// Mounts the real panel markup and calls the real binders, the same way +// promptWallet.test.ts does. +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createAppMarkup, getAppElements } from "../../src/ui/appMarkup"; +import { getInpaintReadinessStatusLabel } from "../../src/ui/inpaintReadiness"; +import { + bindPromptRequiredReset, + isPromptRequiredErrorStale, + PROMPT_REQUIRED_STATUS, + PROMPT_REQUIRED_TOOLS +} from "../../src/ui/promptRequiredReset"; +import { bindPromptWallet, PROMPT_WALLET_TOOLS } from "../../src/ui/promptWallet"; +import { + setGlobalStatus, + setTextToImageError, + setTextToImageStatus, + setOutpaintError, + setOutpaintStatus +} from "../../src/ui/statusBars"; +import { savePromptWallet } from "../../src/utils/preferences"; + +type StorageStub = Storage | undefined; + +function setStorage(value: StorageStub) { + (globalThis as { localStorage?: StorageStub }).localStorage = value; +} + +function createMemoryStorage(): Storage { + const entries = new Map(); + return { + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => void entries.set(key, value), + removeItem: (key: string) => void entries.delete(key) + } as unknown as Storage; +} + +const originalLocalStorage = (globalThis as { localStorage?: StorageStub }).localStorage; + +function mount() { + const root = document.createElement("div"); + root.innerHTML = createAppMarkup(); + document.body.replaceChildren(root); + return getAppElements(root); +} + +function typeInto(field: HTMLTextAreaElement, value: string) { + field.value = value; + field.dispatchEvent(new Event("input", { bubbles: true })); +} + +/** What handleGenerate does when the prompt is empty. */ +function pressGenerateWithNoPrompt(elements: ReturnType) { + setTextToImageError(elements, "Enter a prompt before generating."); + setTextToImageStatus(elements, PROMPT_REQUIRED_STATUS, "error"); +} + +beforeEach(() => { + setStorage(createMemoryStorage()); +}); + +afterEach(() => { + setStorage(originalLocalStorage); +}); + +describe("isPromptRequiredErrorStale", () => { + it("is stale once the field has text again", () => { + expect(isPromptRequiredErrorStale("Prompt required.", "a lighthouse at dusk")).toBe(true); + }); + + it("is not stale while the field is still empty or whitespace", () => { + expect(isPromptRequiredErrorStale("Prompt required.", "")).toBe(false); + expect(isPromptRequiredErrorStale("Prompt required.", " \n")).toBe(false); + }); + + it("reads UXP's null for an empty field as empty", () => { + expect(isPromptRequiredErrorStale("Prompt required.", null)).toBe(false); + expect(isPromptRequiredErrorStale(null, "a lighthouse")).toBe(false); + }); + + it("never treats any other status as a prompt error", () => { + for (const status of ["Ready.", "Generation failed.", "ComfyUI is offline.", "Setup required.", "Mask required."]) { + expect(isPromptRequiredErrorStale(status, "a lighthouse")).toBe(false); + } + }); +}); + +describe("bindPromptRequiredReset", () => { + it("clears the error line and the Error pill when a prompt is typed", () => { + const elements = mount(); + bindPromptRequiredReset(elements); + pressGenerateWithNoPrompt(elements); + + expect(elements.errorMessage.hidden).toBe(false); + expect(elements.statusPill.classList.contains("error")).toBe(true); + + typeInto(elements.prompt, "a lighthouse at dusk"); + + expect(elements.errorMessage.hidden).toBe(true); + expect(elements.errorMessage.textContent).toBe(""); + expect(elements.statusText.textContent).toBe("Ready."); + expect(elements.statusPill.classList.contains("error")).toBe(false); + }); + + it("keeps the error while the field is still empty", () => { + const elements = mount(); + bindPromptRequiredReset(elements); + pressGenerateWithNoPrompt(elements); + + typeInto(elements.prompt, " "); + + expect(elements.statusText.textContent).toBe(PROMPT_REQUIRED_STATUS); + expect(elements.errorMessage.hidden).toBe(false); + }); + + it("leaves every other error alone", () => { + const elements = mount(); + bindPromptRequiredReset(elements); + setTextToImageError(elements, "ComfyUI returned an error."); + setTextToImageStatus(elements, "Generation failed.", "error"); + + typeInto(elements.prompt, "a lighthouse at dusk"); + + expect(elements.statusText.textContent).toBe("Generation failed."); + expect(elements.errorMessage.textContent).toBe("ComfyUI returned an error."); + }); + + it("only clears the tool whose field changed", () => { + const elements = mount(); + bindPromptRequiredReset(elements); + pressGenerateWithNoPrompt(elements); + setOutpaintError(elements, "Enter a prompt before generating Outpaint."); + setOutpaintStatus(elements, PROMPT_REQUIRED_STATUS, "error"); + + typeInto(elements.prompt, "a lighthouse at dusk"); + + expect(elements.statusText.textContent).toBe("Ready."); + expect(elements.outpaintStatusText.textContent).toBe(PROMPT_REQUIRED_STATUS); + expect(elements.outpaintErrorMessage.hidden).toBe(false); + }); + + it("survives a UXP-style null value without throwing", () => { + const elements = mount(); + bindPromptRequiredReset(elements); + pressGenerateWithNoPrompt(elements); + Object.defineProperty(elements.prompt, "value", { configurable: true, get: () => null }); + + expect(() => elements.prompt.dispatchEvent(new Event("input", { bubbles: true }))).not.toThrow(); + expect(elements.statusText.textContent).toBe(PROMPT_REQUIRED_STATUS); + }); + + it("does not break the panel when a field is missing", () => { + const elements = mount(); + const broken = [{ ...PROMPT_REQUIRED_TOOLS[0], prompt: "doesNotExist" as never }]; + + expect(() => bindPromptRequiredReset(elements, broken)).not.toThrow(); + }); +}); + +describe("finding 4: loading a prompt from the Wallet", () => { + it("clears Text to Image's Prompt required error", () => { + savePromptWallet([ + { + id: "1", + name: "Lighthouse", + positivePrompt: "a lighthouse at dusk", + negativePrompt: "blurry", + pinned: false, + createdAt: "2026-11-05T00:00:00.000Z" + } + ]); + const elements = mount(); + // Same order as renderApp: the Wallet, then the reset. + bindPromptWallet(elements, PROMPT_WALLET_TOOLS, () => undefined); + bindPromptRequiredReset(elements); + setGlobalStatus(elements, "Ready.", "idle"); + pressGenerateWithNoPrompt(elements); + + elements.promptWalletLoad.click(); + const use = elements.promptWalletList.querySelector(".prompt-wallet-use"); + expect(use).not.toBeNull(); + use!.click(); + + expect(elements.prompt.value).toBe("a lighthouse at dusk"); + expect(elements.statusText.textContent).toBe("Ready."); + expect(elements.statusPill.classList.contains("error")).toBe(false); + expect(elements.errorMessage.hidden).toBe(true); + expect(elements.diagnosticsText.textContent).toBe("Prompt loaded from Wallet."); + }); +}); + +describe("prompt-required registration", () => { + const app = readFileSync(resolve(__dirname, "../../src/ui/App.ts"), "utf8"); + + it("is the only way App.ts spells the prompt-required status", () => { + expect(app).not.toContain('"Prompt required."'); + }); + + it("lists every tool App.ts stops with Prompt required", () => { + const setters = [...app.matchAll(/(set\w+Status)\(elements, PROMPT_REQUIRED_STATUS, "error"\)/g)].map( + (match) => match[1] + ); + const registered = PROMPT_REQUIRED_TOOLS.map((tool) => tool.setStatus.name); + + expect(setters.length).toBeGreaterThan(0); + for (const setter of setters) { + expect(registered).toContain(setter); + } + }); + + it("covers Inpaint, which reaches the same status through its readiness gate", () => { + expect(getInpaintReadinessStatusLabel("prompt-missing")).toBe(PROMPT_REQUIRED_STATUS); + expect(PROMPT_REQUIRED_TOOLS.map((tool) => tool.setStatus.name)).toContain("setInpaintStatus"); + }); + + it("pairs each tool's prompt field with its own status bar", () => { + for (const tool of PROMPT_REQUIRED_TOOLS) { + // A fresh panel per tool, so a wrong pairing cannot pass by reading a + // status bar an earlier iteration already wrote. + const elements = mount(); + expect(elements[tool.prompt]).toBeInstanceOf(HTMLTextAreaElement); + tool.setStatus(elements, PROMPT_REQUIRED_STATUS, "error"); + expect((elements[tool.statusText] as HTMLElement).textContent).toBe(PROMPT_REQUIRED_STATUS); + } + }); +});