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); + } + }); +});