From eb56956ef553ff953abf4461a6021a6e459d6e2f Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 2 Sep 2026 04:32:56 +0000 Subject: [PATCH 1/3] fix: stop the Image Gen backend probe from disabling the whole prompt form (#5671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing on /media/image greyed out the entire above-the-fold form until the Stable Diffusion status probe answered — and did it again on every backend-chip or model switch. An unconfigured external SD API URL makes that probe time out, so the page's primary CTA was effectively dead on first paint. The probe decides WHICH backend can run, not what the user may type. It now gates only backend selection (the chip strip), the Refresh-status button, and the Generate submit button; the prompt and negative-prompt textareas, universe and style pickers, prompt helpers, LoRA/init/reference pickers and every parameter input stay live while the status pill is still checking. Generate shows a "Checking…" label and an explanatory tooltip while it waits, so the disabled state reads as progress rather than a mystery grey button. This matches VideoGen, which gates exactly one control. Claude-Session: https://claude.ai/code/session_01DxNA8g7B5hZd4uswfUM1xn --- client/src/pages/ImageGen.jsx | 25 ++-- .../src/pages/ImageGen.probeGating.test.jsx | 111 ++++++++++++++++++ 2 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 client/src/pages/ImageGen.probeGating.test.jsx diff --git a/client/src/pages/ImageGen.jsx b/client/src/pages/ImageGen.jsx index be4a7c2374..438a5a1f73 100644 --- a/client/src/pages/ImageGen.jsx +++ b/client/src/pages/ImageGen.jsx @@ -1293,12 +1293,10 @@ export default function ImageGen() {
@@ -1306,7 +1304,6 @@ export default function ImageGen() { value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={3} - disabled={statusLoading} className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent disabled:opacity-50 min-h-[80px]" placeholder="Describe the image you want to generate..." /> @@ -1316,7 +1313,6 @@ export default function ImageGen() { value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} rows={3} - disabled={statusLoading} className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent disabled:opacity-50 min-h-[80px]" placeholder="What to avoid..." /> @@ -1330,14 +1326,12 @@ export default function ImageGen() { negativePrompt={negativePrompt} setNegativePrompt={setNegativePrompt} renderConfig={{ stylePreset: stylePreset?.id, mode: effectiveMode }} - disabled={statusLoading} /> {flux2Issue === 'venv' && ( @@ -1349,7 +1343,6 @@ export default function ImageGen() { {editImageMissing && ( diff --git a/client/src/pages/ImageGen.probeGating.test.jsx b/client/src/pages/ImageGen.probeGating.test.jsx new file mode 100644 index 0000000000..23f3644c05 --- /dev/null +++ b/client/src/pages/ImageGen.probeGating.test.jsx @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; + +const MODEL = { id: 'dev', name: 'FLUX.1 Dev', runner: 'mflux', steps: 20, guidance: 3.5 }; + +// The backend probe is held open on purpose: an unconfigured `external` SD API +// URL times out, and that window used to grey out the whole form. +const state = vi.hoisted(() => ({ resolveStatus: null, statusPromise: null })); + +vi.mock('../services/api', () => ({ + getInstances: vi.fn(async () => ({ peers: [] })), + getImageGenStatus: vi.fn(() => state.statusPromise), + generateImage: vi.fn(async () => ({ jobId: 'job-1' })), + generateImageMultipart: vi.fn(async () => ({})), + listImageModels: vi.fn(async () => [MODEL]), + listLorasFull: vi.fn(async () => []), + listImageGallery: vi.fn(async () => []), + cancelImageGen: vi.fn(async () => ({})), + deleteImage: vi.fn(async () => ({})), + setImageHidden: vi.fn(async () => ({})), + cleanGalleryImage: vi.fn(async () => ({})), + getActiveImageJob: vi.fn(async () => ({ activeJob: null })), + getSettings: vi.fn(async () => ({ imageGen: { mode: 'local', local: { pythonPath: '/usr/bin/python3' } } })), + buildFormData: vi.fn(() => new FormData()), + listMediaJobs: vi.fn(async () => ({ jobs: [] })), + regenerateGalleryImage: vi.fn(async () => ({})), + getRegenAvailability: vi.fn(async () => ({ available: false })), + removeImageWatermark: vi.fn(async () => ({})), + getFlux2Status: vi.fn(async () => ({ installed: true, ready: true })), +})); + +vi.mock('../hooks/useImageGenProgress', () => ({ + useImageGenProgress: () => ({ progress: null, begin: vi.fn(), end: vi.fn(), resume: vi.fn() }), +})); +vi.mock('../hooks/useMediaJobSse', () => ({ + useMediaJobSse: () => ({ attach: vi.fn(), eventSourceRef: { current: null } }), +})); +vi.mock('../hooks/useModelDownloadStatus', () => ({ + useModelDownloadStatus: () => ({ + getStatus: () => ({ cached: true }), start: vi.fn(), cancel: vi.fn(), repair: vi.fn(), refresh: vi.fn(), + downloading: false, repairing: false, progress: null, lastError: null, activeModelId: null, extra: {}, loading: false, statusError: null, + }), +})); +vi.mock('../hooks/useHfTokenStatus', () => ({ useHfTokenStatus: () => ({ present: true, refresh: vi.fn() }) })); +vi.mock('../hooks/useAgyModels', () => ({ useAgyModels: () => ({ models: [], error: null }) })); +vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() })); +vi.mock('../hooks/useMediaAnnotations', () => ({ + useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }), +})); +vi.mock('../hooks/useAutoRefetch', () => ({ useAutoRefetch: vi.fn() })); +vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] })); +vi.mock('../components/ui/Toast', () => ({ + default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }), +})); +vi.mock('../components/media/PromptEnhancer', () => ({ default: () => null })); +vi.mock('../components/media/PromptFromMedia', () => ({ default: () => null })); +vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null })); +vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null })); +vi.mock('../components/media/MediaPreview', () => ({ default: () => null })); +vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null })); +vi.mock('../components/media/ResolutionField', () => ({ default: () => null })); +vi.mock('../components/Drawer', () => ({ default: () => null })); +vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null })); +vi.mock('../components/imageGen/Flux2InstallModal', () => ({ default: () => null })); +vi.mock('../components/imageGen/GalleryImagePicker', () => ({ default: () => null })); +vi.mock('../components/imageGen/InitImagePicker', () => ({ default: () => null })); +vi.mock('../components/imageGen/ReferenceImagePicker', () => ({ default: () => null })); +vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null })); + +const { default: ImageGen } = await import('./ImageGen.jsx'); + +const mount = async () => { + await act(async () => { + render( + + + , + ); + }); +}; + +describe('ImageGen backend-probe gating', () => { + beforeEach(() => { + state.statusPromise = new Promise((resolve) => { state.resolveStatus = resolve; }); + }); + + // The probe decides which backend can RUN, not what the user may TYPE. While + // it is in flight the whole above-the-fold form must stay usable. + it('leaves the prompt fields editable while the status probe is still in flight', async () => { + await mount(); + + expect(await screen.findByLabelText('Prompt')).not.toBeDisabled(); + expect(screen.getByLabelText('Negative Prompt')).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /Checking…/ })).toBeDisabled(); + }); + + // A probe that comes back unusable must still not take the form hostage — + // only submit stays blocked, so the user can compose while they fix settings. + it('keeps the prompt editable and submit blocked when the probe reports not connected', async () => { + await mount(); + await act(async () => { + state.resolveStatus({ connected: false, mode: 'local', reason: 'Not configured' }); + await state.statusPromise; + }); + + await waitFor(() => expect(screen.getByRole('button', { name: /^Generate$/ })).toBeDisabled()); + expect(screen.getByLabelText('Prompt')).not.toBeDisabled(); + expect(screen.getByLabelText('Negative Prompt')).not.toBeDisabled(); + }); +}); From b71ed5122e1388377e4f26595e92d0c24737e7da Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 2 Sep 2026 04:40:28 +0000 Subject: [PATCH 2/3] fix: gate Image Gen implicit submit on the backend probe (#5671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enabling the form during the status probe re-opens the implicit-submit path: Enter inside a number input fires the form's onSubmit even though the default Generate button is disabled. handleGenerate already carries that reasoning for the edit-image and cloud-prompt hints; extend the same guard to the probe so a submit can't dispatch against an unconfirmed or unreachable backend. A remote render target runs on the peer, so the local probe result does not gate it — the guard mirrors the submit button exactly. Claude-Session: https://claude.ai/code/session_01DxNA8g7B5hZd4uswfUM1xn --- client/src/pages/ImageGen.jsx | 5 +++++ .../src/pages/ImageGen.probeGating.test.jsx | 20 ++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/client/src/pages/ImageGen.jsx b/client/src/pages/ImageGen.jsx index 438a5a1f73..a1edde672a 100644 --- a/client/src/pages/ImageGen.jsx +++ b/client/src/pages/ImageGen.jsx @@ -983,6 +983,11 @@ export default function ImageGen() { // fires onSubmit — gate here too so an edit-only model without a source image // (or codex text-to-image with no prompt) hits the inline hint, not a 400 toast. if (editImageMissing || cloudNeedsPrompt) return; + // Same reason, for the backend probe: the form stays typable while the status + // pill is still checking, so an implicit submit must not dispatch against a + // backend we haven't confirmed. A remote target runs on the peer, so the + // LOCAL probe result doesn't gate it — mirror the submit button exactly. + if (statusLoading || (!remoteTargetActive && notConnected)) return; // The button reading is as old as the last render and a capacity window // expires on the clock, so an enabled button can already be pointing at a // lapsed peer. Re-derive here and say so, rather than letting the server diff --git a/client/src/pages/ImageGen.probeGating.test.jsx b/client/src/pages/ImageGen.probeGating.test.jsx index 23f3644c05..1883e2d05e 100644 --- a/client/src/pages/ImageGen.probeGating.test.jsx +++ b/client/src/pages/ImageGen.probeGating.test.jsx @@ -1,17 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; const MODEL = { id: 'dev', name: 'FLUX.1 Dev', runner: 'mflux', steps: 20, guidance: 3.5 }; // The backend probe is held open on purpose: an unconfigured `external` SD API // URL times out, and that window used to grey out the whole form. -const state = vi.hoisted(() => ({ resolveStatus: null, statusPromise: null })); +const state = vi.hoisted(() => ({ resolveStatus: null, statusPromise: null, generateImage: vi.fn() })); vi.mock('../services/api', () => ({ getInstances: vi.fn(async () => ({ peers: [] })), getImageGenStatus: vi.fn(() => state.statusPromise), - generateImage: vi.fn(async () => ({ jobId: 'job-1' })), + generateImage: (...args) => state.generateImage(...args), generateImageMultipart: vi.fn(async () => ({})), listImageModels: vi.fn(async () => [MODEL]), listLorasFull: vi.fn(async () => []), @@ -82,6 +82,7 @@ const mount = async () => { describe('ImageGen backend-probe gating', () => { beforeEach(() => { + state.generateImage.mockReset().mockResolvedValue({ jobId: 'job-1' }); state.statusPromise = new Promise((resolve) => { state.resolveStatus = resolve; }); }); @@ -108,4 +109,17 @@ describe('ImageGen backend-probe gating', () => { expect(screen.getByLabelText('Prompt')).not.toBeDisabled(); expect(screen.getByLabelText('Negative Prompt')).not.toBeDisabled(); }); + + // A live form has a live implicit submit: Enter inside a number input fires + // onSubmit even when the default button is disabled, so the handler carries + // the same probe gate the button does. + it('refuses an implicit submit fired while the probe is still in flight', async () => { + await mount(); + + const prompt = await screen.findByLabelText('Prompt'); + fireEvent.change(prompt, { target: { value: 'a lighthouse at dusk' } }); + await act(async () => { fireEvent.submit(prompt.closest('form')); }); + + expect(state.generateImage).not.toHaveBeenCalled(); + }); }); From e7542353250bbee982a489988e7649c98236cc56 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 2 Sep 2026 04:51:03 +0000 Subject: [PATCH 3/3] fix: don't let the local image probe gate a federated render (#5671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A federated render target dispatches to the peer, so this machine's Stable Diffusion probe — the one that hangs against an unconfigured external SD API URL — has nothing to say about whether it can run. Fold the probe into a single `localBackendPending` predicate that is false whenever a remote target is selected, and use it for the Generate button's disabled state, its "Checking…" label and tooltip, and the implicit-submit guard. Claude-Session: https://claude.ai/code/session_01DxNA8g7B5hZd4uswfUM1xn --- client/src/pages/ImageGen.jsx | 14 ++++--- .../src/pages/ImageGen.probeGating.test.jsx | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/client/src/pages/ImageGen.jsx b/client/src/pages/ImageGen.jsx index a1edde672a..7cd7a4de17 100644 --- a/client/src/pages/ImageGen.jsx +++ b/client/src/pages/ImageGen.jsx @@ -250,6 +250,10 @@ export default function ImageGen() { // apply to it. Keep the target state intact so switching back to another // backend restores the user's previous target selection. const remoteTargetActive = effectiveMode !== IMAGE_GEN_MODE.GROK && remoteTarget.isRemote; + // The status probe describes THIS machine's backend, and it can hang for a long + // time against an unconfigured external SD API URL. A federated target renders + // on the peer, so only a LOCAL dispatch waits on the probe. + const localBackendPending = statusLoading && !remoteTargetActive; const isAsyncMode = isLocalMode || isCloudMode || remoteTargetActive; // Only probe `agy models` while Agy is the active backend — it spawns a // child process server-side, so an unselected backend must not pay for it. @@ -987,7 +991,7 @@ export default function ImageGen() { // pill is still checking, so an implicit submit must not dispatch against a // backend we haven't confirmed. A remote target runs on the peer, so the // LOCAL probe result doesn't gate it — mirror the submit button exactly. - if (statusLoading || (!remoteTargetActive && notConnected)) return; + if (localBackendPending || (!remoteTargetActive && notConnected)) return; // The button reading is as old as the last render and a capacity window // expires on the clock, so an enabled button can already be pointing at a // lapsed peer. Re-derive here and say so, rather than letting the server @@ -1473,15 +1477,15 @@ export default function ImageGen() { // The probe decides WHICH backend can run, not what the user may // type — so it gates submit and backend selection only. Every form // control above stays live while the status pill is still checking. - disabled={statusLoading || (remoteTargetActive + disabled={remoteTargetActive ? remoteBlocked !== null - : (notConnected || editImageMissing || cloudNeedsPrompt))} - title={statusLoading + : (localBackendPending || notConnected || editImageMissing || cloudNeedsPrompt)} + title={localBackendPending ? 'Checking the image backend…' : remoteBlocked || (editImageMissing ? 'This image-edit model needs a source image — upload one below first' : cloudNeedsPrompt ? cloudPromptHint : undefined)} className="flex items-center gap-2 px-4 py-2 bg-port-accent hover:bg-port-accent/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg min-h-[40px]" > - {statusLoading ? 'Checking…' : generating ? 'Queue' : 'Generate'} + {localBackendPending ? 'Checking…' : generating ? 'Queue' : 'Generate'} {isAsyncMode && batchCount > 1 && × {batchCount}} {editImageMissing && ( diff --git a/client/src/pages/ImageGen.probeGating.test.jsx b/client/src/pages/ImageGen.probeGating.test.jsx index 1883e2d05e..35b37714dd 100644 --- a/client/src/pages/ImageGen.probeGating.test.jsx +++ b/client/src/pages/ImageGen.probeGating.test.jsx @@ -4,6 +4,29 @@ import { MemoryRouter } from 'react-router'; const MODEL = { id: 'dev', name: 'FLUX.1 Dev', runner: 'mflux', steps: 20, guidance: 3.5 }; +// A peer opted in as an image provider with a live capacity window — the shape +// `GET /api/instances` returns. +const PEER = { + id: 'peer-example', + name: 'Example GPU', + status: 'online', + enabled: true, + mediaProvider: { enabled: true, imageModels: [{ engine: 'local', modelId: 'peer-flux' }] }, + mediaProviderStatus: { + state: 'ready', + checkedAt: new Date().toISOString(), + freshUntil: new Date(Date.now() + 60_000).toISOString(), + snapshot: { + queue: { accepting: true, running: 0, queued: 0, totalActive: 0, maxQueuedJobs: 4 }, + capabilities: [{ + kind: 'image', engine: 'local', engineName: 'Local image', modelId: 'peer-flux', + modelName: 'FLUX.2 Klein', ready: true, unavailableReason: null, + runtimeReady: true, platformSupported: true, cudaRequired: false, cudaState: 'available', + }], + }, + }, +}; + // The backend probe is held open on purpose: an unconfigured `external` SD API // URL times out, and that window used to grey out the whole form. const state = vi.hoisted(() => ({ resolveStatus: null, statusPromise: null, generateImage: vi.fn() })); @@ -122,4 +145,22 @@ describe('ImageGen backend-probe gating', () => { expect(state.generateImage).not.toHaveBeenCalled(); }); + + // A federated render runs on the peer, so THIS machine's probe — hung against + // an unconfigured SD API URL — must not hold the submit hostage. + it('still submits to a ready peer while the local probe hangs', async () => { + const { getInstances } = await import('../services/api'); + getInstances.mockResolvedValueOnce({ peers: [PEER] }); + await mount(); + + fireEvent.change(await screen.findByRole('combobox', { name: /generation target/i }), { target: { value: 'peer-example' } }); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'a lighthouse at dusk' } }); + + const generate = screen.getByRole('button', { name: /^Generate$/ }); + expect(generate).not.toBeDisabled(); + await act(async () => { fireEvent.click(generate); }); + + await waitFor(() => expect(state.generateImage).toHaveBeenCalled()); + expect(state.generateImage.mock.calls[0][0]).toMatchObject({ mediaProviderPeerId: 'peer-example' }); + }); });