From bc321b0c794a8883128690a5d2534e0837228a64 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 15 Oct 2026 22:15:30 -0400 Subject: [PATCH] feat(gallery): reveal external generations that opt in with gallery:reveal Completed items enqueued with destination "gallery:reveal" by another client select their board and last saved image in a visible tab, standing down whenever the user is busy or has navigated; hidden tabs defer until shown. Image-map cluster selections hold a locator request so reveals never supersede them. Co-Authored-By: Claude Opus 5.5 --- .../frontend/webv2/public/locales/en.json | 1 + .../webv2/src/app/GalleryRealtimeRuntime.tsx | 27 +- .../webv2/src/features/gallery/README.md | 25 + .../webv2/src/features/gallery/contracts.ts | 6 + .../src/features/gallery/core/selection.ts | 57 ++- .../gallery/data/realtimeRuntime.test.ts | 42 +- .../features/gallery/data/realtimeRuntime.ts | 30 +- .../ui/GalleryWidgetView.browser.test.tsx | 113 ++++- .../features/gallery/ui/GalleryWidgetView.tsx | 20 +- .../src/features/queue/data/serverTypes.ts | 1 + .../features/queue/data/submissionApi.test.ts | 68 +++ .../src/features/queue/data/submissionApi.ts | 40 ++ .../webv2/src/features/queue/queries.ts | 1 + .../recallParametersBridge.lazy.test.ts | 17 + .../image-actions/recallParametersBridge.ts | 105 +++++ .../revealExternalGeneration.test.ts | 427 ++++++++++++++++++ .../image-actions/revealExternalGeneration.ts | 116 +++++ .../image-actions/revealGalleryItem.test.ts | 18 + .../image-actions/revealGalleryItem.ts | 20 +- .../useSelectMapImage.browser.test.tsx | 22 +- .../widgets/image-map/useSelectMapImage.ts | 5 +- 21 files changed, 1116 insertions(+), 45 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.lazy.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.ts diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 789997eed1e..0a10a1a53d1 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -4202,6 +4202,7 @@ "boardVisibility": "Board visibility", "boardsLoadFailed": "Could not load boards.", "boardsRefreshFailed": "Could not refresh boards.", + "externalGenerationRevealed": "Showing an image from another app", "destination": "Destination", "followSelectedBoard": "Send new results to the selected board", "commands": { diff --git a/invokeai/frontend/webv2/src/app/GalleryRealtimeRuntime.tsx b/invokeai/frontend/webv2/src/app/GalleryRealtimeRuntime.tsx index 72289a5fb6f..2892829b2ca 100644 --- a/invokeai/frontend/webv2/src/app/GalleryRealtimeRuntime.tsx +++ b/invokeai/frontend/webv2/src/app/GalleryRealtimeRuntime.tsx @@ -4,25 +4,48 @@ import { isWebv2QueueItemOrigin } from '@features/queue/contracts'; import { useMountEffect } from '@platform/react/useMountEffect'; import { socketHub } from '@platform/transport/socketHub'; import { useQueryClient } from '@tanstack/react-query'; +import { isEditableHotkeyTarget } from '@workbench/hotkeys/keys'; +import { attachExternalGenerationReveal } from '@workbench/image-actions/recallParametersBridge'; +import { useWorkbenchCommands, useWorkbenchQueries } from '@workbench/WorkbenchContext'; /** * App-owned composition: uploads and other clients' completed generations the backend announces over the socket - * refresh the Gallery read model. + * refresh the Gallery read model, and those whose batch asked for it are revealed in the Gallery. */ export const GalleryRealtimeRuntime = () => { const queryClient = useQueryClient(); + const commands = useWorkbenchCommands(); + const queries = useWorkbenchQueries(); useMountEffect(() => { + const revealer = attachExternalGenerationReveal({ + context: { + commands, + // A field keeps focus when the user switches to another window, such as the chatbot beside Invoke. + isEditingText: () => document.hasFocus() && isEditableHotkeyTarget(document.activeElement), + isVisible: () => document.visibilityState === 'visible', + onVisibilityChange: (listener) => { + document.addEventListener('visibilitychange', listener); + return () => document.removeEventListener('visibilitychange', listener); + }, + queries, + queryClient, + }, + }); const runtime = createGalleryRealtimeRuntime({ backend: socketHub, getSessionUserId, invalidate: () => invalidateGallery(queryClient), isClientQueueItemOrigin: isWebv2QueueItemOrigin, + onRevealRequest: revealer.request, }); runtime.start(); - return runtime.dispose; + return () => { + runtime.dispose(); + revealer.dispose(); + }; }); return null; diff --git a/invokeai/frontend/webv2/src/features/gallery/README.md b/invokeai/frontend/webv2/src/features/gallery/README.md index 65eaa4011ad..49359ef2c1d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/README.md +++ b/invokeai/frontend/webv2/src/features/gallery/README.md @@ -3,3 +3,28 @@ Paths are relative to the webv2 `src/` directory. Gallery ordinary listings are Query-owned sparse pages shared by grid, picker, Preview, and deep-reveal flows. Page key = account lifetime + canonical listing filters + aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. Cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by recent-item limit so an authoritative item does not reappear in overlay when its page is evicted. Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies item at returned absolute position, and retries that rank/page check at most once if offsets shifted. Persisted Preview selection keeps listing identity from when item was selected; changing active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations; its async navigation is fenced by captured account epoch so a late page cannot stamp selection into a newer account lifetime. + +## Realtime refresh and external reveal + +Uploads, reconnects, and the signed-in user's completed queue items whose origin does not start with `webv2:` refresh the listings. This client's own items are refreshed by Queue, in the tab that tracks them only. + +An external client asks for its results to be revealed by enqueueing with `POST /api/v1/queue/default/enqueue_batch` and `batch.destination` set to `gallery:reveal`. The request is honored only for the signed-in user's items (any user's on a single-user server) whose origin does not start with `webv2:`. Every other destination only refreshes, `gallery` included: legacy web sends it on every workflow and upscale run. + +The reveal is best effort. Each completed item asks once; a reveal that stands down is dropped, not retried, except in a hidden tab. Every visible tab or device signed in as that user may reveal. The reveal takes the last non-intermediate image the item saved and lands on it as Find in Gallery does: board, page and selection, plus the starred-only filter and the images/assets view when the item needs them, after which Preview follows the selection and the grid scrolls to it. With the Gallery widget closed, the selection still changes, unseen. It raises no widget and leaves Canvas, Generate panels and the route untouched. Only while the Gallery widget is mounted is it announced, once, in the Gallery's polite live region; a Gallery mounted later does not repeat it. + +It never takes the Gallery from the user. These conditions are checked when the completion arrives, before and after its result is read, and immediately before the first Gallery change; the first failure drops the reveal: + +- the user is not typing (this window has focus and a text field in it does); +- the project on screen at arrival is still the active one and has no open run of its own; +- no search text, semantic text or semantic query narrows the listing (an open but empty semantic field does not count); +- at most one item is selected; +- no other reveal, such as Find in Gallery or an image-map click, is locating its item; +- the user has not navigated the Gallery since arrival: board, selection, view, starred-only, search, page, or a claimed navigation; +- no newer reveal request has arrived. Only the newest completion reveals, so a batch of several runs reveals at most its last completed item. + +A hidden tab defers instead: the newest request waits until the tab is shown, and the conditions are checked then. + +Known limitations: + +- The status event does not say whether an item is a workflow-call child, which inherits its parent's destination. A child's completion supersedes a reveal still in flight before its read finds it is a child and reveals nothing; if the parent then fails or is cancelled, the earlier item is not revealed. +- An image a graph passes through from its inputs counts as an output, so it is picked if it is the last non-intermediate one. diff --git a/invokeai/frontend/webv2/src/features/gallery/contracts.ts b/invokeai/frontend/webv2/src/features/gallery/contracts.ts index 0d1e16a746a..c3ffd5d0e09 100644 --- a/invokeai/frontend/webv2/src/features/gallery/contracts.ts +++ b/invokeai/frontend/webv2/src/features/gallery/contracts.ts @@ -75,6 +75,7 @@ export { type GallerySelectedImageQuery, } from './ui/galleryStateView'; export { + captureGalleryNavigation, claimGalleryNavigationSequence, getGalleryDeletionSuccessor, getGalleryNavigationStep, @@ -82,8 +83,13 @@ export { getPersistedSelectedGalleryItemKeys, getSelectedGalleryImageFromValues, getSelectedGalleryItemFromValues, + hasGalleryListingSearch, + hasGalleryLocatorRequests, isGalleryNavigationCurrent, + isGalleryNavigationStateUnchanged, requestGalleryItemReveal, + type GalleryNavigationSnapshot, + type GalleryRevealAnnouncement, type GalleryNavigationDirection, type GalleryNavigationEntry, } from './core/selection'; diff --git a/invokeai/frontend/webv2/src/features/gallery/core/selection.ts b/invokeai/frontend/webv2/src/features/gallery/core/selection.ts index e5a2286156e..61ba294822c 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/selection.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/selection.ts @@ -135,9 +135,16 @@ export const getGalleryDeletionSuccessor = ( * scrolls; colocating avoids another gallery chunk. */ +/** + * Why a reveal is announced, once, by a Gallery widget mounted when it is requested; unset for reveals the user asked + * for themselves. + */ +export type GalleryRevealAnnouncement = 'external-generation'; + export interface GalleryRevealRequest { accountSignal: AbortSignal; absoluteIndex?: number; + announcement?: GalleryRevealAnnouncement; itemKey: GalleryItemKey; token: number; } @@ -150,10 +157,11 @@ const listeners = new Set<() => void>(); export const requestGalleryItemReveal = ( itemKey: GalleryItemKey, accountSignal: AbortSignal, - absoluteIndex?: number + absoluteIndex?: number, + announcement?: GalleryRevealAnnouncement ): void => { nextToken += 1; - currentRequest = { accountSignal, absoluteIndex, itemKey, token: nextToken }; + currentRequest = { accountSignal, absoluteIndex, announcement, itemKey, token: nextToken }; for (const listener of listeners) { listener(); } @@ -197,6 +205,9 @@ export const createGalleryLocatorRequest = (): GalleryLocatorRequest => { }; }; +/** Whether a reveal is still locating its item; a reveal nobody asked for must not supersede it. */ +export const hasGalleryLocatorRequests = (): boolean => activeGalleryLocatorRequests.size > 0; + /** A later Gallery navigation supersedes every locator currently in flight. */ export const abortGalleryLocatorRequests = (): void => { for (const controller of activeGalleryLocatorRequests) { @@ -214,6 +225,48 @@ export const claimGalleryNavigationSequence = (): number => ++navigationSequence /** False once a newer navigation has been claimed, which is when a slow hydrate must stand down. */ export const isGalleryNavigationCurrent = (sequence: number): boolean => sequence === navigationSequence; +/** + * The Gallery navigation a user controls, as one comparable value: the latest claimed navigation, plus the board, + * selection, view, filters, search and page in the Gallery widget's values. Ordinary clicks claim no navigation, so + * the values cover them. + */ +export interface GalleryNavigationSnapshot { + readonly sequence: number; + readonly state: string; +} + +const NAVIGATION_VALUE_KEYS = [ + 'galleryPage', + 'galleryView', + 'searchTerm', + 'selectedBoardId', + 'selectedImageName', + 'selectedImageNames', + 'semanticImageQuery', + 'semanticSearchText', + 'starredOnly', +] as const; + +export const captureGalleryNavigation = (values: Record): GalleryNavigationSnapshot => ({ + sequence: navigationSequence, + state: JSON.stringify(NAVIGATION_VALUE_KEYS.map((key) => values[key] ?? null)), +}); + +/** Whether the Gallery values still hold the captured navigation; the claimed sequence is compared separately. */ +export const isGalleryNavigationStateUnchanged = ( + snapshot: GalleryNavigationSnapshot, + values: Record +): boolean => captureGalleryNavigation(values).state === snapshot.state; + +/** + * Whether the user composed a search a reveal would discard: search text, semantic text, or a semantic query. An open + * but empty semantic field holds nothing to lose. + */ +export const hasGalleryListingSearch = (values: Record): boolean => + (typeof values.searchTerm === 'string' && values.searchTerm !== '') || + (typeof values.semanticSearchText === 'string' && values.semanticSearchText !== '') || + (values.semanticImageQuery !== null && values.semanticImageQuery !== undefined); + /* * Grid and Preview share section order: the starred strip, the in-progress sessions, then the listing. Horizontal * navigation crosses seams while vertical navigation preserves columns. diff --git a/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.test.ts index c0183893830..ec223f22f19 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.test.ts @@ -30,6 +30,7 @@ describe('gallery realtime runtime', () => { const invalidate = vi.fn(); let sessionUserId: string | null = 'alice'; const getSessionUserId = () => sessionUserId; + const onRevealRequest = vi.fn(); const CLIENT_ORIGIN = 'this-client'; const createRuntime = () => createGalleryRealtimeRuntime({ @@ -37,9 +38,18 @@ describe('gallery realtime runtime', () => { getSessionUserId, invalidate, isClientQueueItemOrigin: (origin) => origin === CLIENT_ORIGIN, + onRevealRequest, }); - const emitStatusChanged = (event: { origin?: string | null; status?: string; user_id?: string }): void => + const emitStatusChanged = (event: { + destination?: string | null; + item_id?: number; + origin?: string | null; + status?: string; + user_id?: string; + }): void => handlers.get('queue_item_status_changed')?.({ + destination: null, + item_id: 1, origin: 'chatbot', status: 'completed', user_id: 'alice', @@ -56,6 +66,7 @@ describe('gallery realtime runtime', () => { backend.on.mockClear(); backend.onConnectionChange.mockClear(); invalidate.mockReset(); + onRevealRequest.mockReset(); }); afterEach(() => vi.useRealTimers()); @@ -181,21 +192,42 @@ describe('gallery realtime runtime', () => { it('ignores completions that cannot add to this gallery or that Queue already refreshes for', () => { const runtime = createRuntime(); + const destination = 'gallery:reveal'; runtime.start(); // Another user's item, as an admin receives it, and as everyone else receives it. - emitStatusChanged({ user_id: 'bob' }); - emitStatusChanged({ origin: null, user_id: 'redacted' }); + emitStatusChanged({ destination, user_id: 'bob' }); + emitStatusChanged({ destination: null, origin: null, user_id: 'redacted' }); // An item this client enqueued, which Queue refreshes for in the tab tracking it. - emitStatusChanged({ origin: CLIENT_ORIGIN }); + emitStatusChanged({ destination, origin: CLIENT_ORIGIN }); for (const status of ['pending', 'in_progress', 'waiting', 'failed', 'canceled'] as const) { - emitStatusChanged({ status }); + emitStatusChanged({ destination, status }); } vi.advanceTimersByTime(1000); expect(invalidate).not.toHaveBeenCalled(); + expect(onRevealRequest).not.toHaveBeenCalled(); + runtime.dispose(); + }); + + it('hands on only the completions whose batch asked to be revealed', () => { + const runtime = createRuntime(); + + runtime.start(); + emitStatusChanged({ destination: 'gallery:reveal', item_id: 7 }); + // Legacy web sends `gallery` on every workflow and upscale run, so it cannot opt in. + emitStatusChanged({ destination: 'gallery', item_id: 8 }); + emitStatusChanged({ destination: 'canvas', item_id: 9 }); + emitStatusChanged({ item_id: 10 }); + + expect(onRevealRequest.mock.calls).toEqual([[7]]); + + accountLifecycle.invalidate(); + emitStatusChanged({ destination: 'gallery:reveal', item_id: 11 }); + + expect(onRevealRequest).toHaveBeenCalledTimes(1); runtime.dispose(); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.ts b/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.ts index 103e40bb638..b2469654086 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/realtimeRuntime.ts @@ -9,11 +9,23 @@ export interface GalleryRealtimeBackend { /** The fields of the backend's `queue_item_status_changed` payload that Gallery reads. */ interface QueueItemStatusChangedPayload { + destination: string | null; + item_id: number; origin: string | null; status: string; user_id: string; } +/** + * External-client reveal contract. A batch enqueued with `POST /api/v1/queue/default/enqueue_batch` and + * `batch.destination` set to this value, by the signed-in user (any user on a single-user server) and with an origin + * that does not start with `webv2:`, asks for the last saved image of each completed item to be revealed, best + * effort, in that user's open Gallery. Every other destination, `gallery` included (legacy web sends it on every + * workflow and upscale run), only refreshes the Gallery. When the reveal happens, what it changes, and its limits + * (workflow-call children among them) are documented in the Gallery README. + */ +const REVEAL_DESTINATION = 'gallery:reveal'; + export interface GalleryRealtimeRuntime { dispose(): void; start(): void; @@ -31,6 +43,7 @@ export const createGalleryRealtimeRuntime = ({ invalidate, isClientQueueItemOrigin, minIntervalMs = 1000, + onRevealRequest, }: { backend: GalleryRealtimeBackend; coalesceMs?: number; @@ -40,6 +53,8 @@ export const createGalleryRealtimeRuntime = ({ /** True for origins of items this client enqueues; Queue refreshes for those in the tab that tracks them. */ isClientQueueItemOrigin: (origin: string | null) => boolean; minIntervalMs?: number; + /** Receives the backend item id of each of those completions whose batch asked to be revealed. */ + onRevealRequest?: (queueItemId: number) => void; }): GalleryRealtimeRuntime => { const owner = captureAccountScope(); const detachers: Array<() => void> = []; @@ -75,11 +90,18 @@ export const createGalleryRealtimeRuntime = ({ const sessionUserId = getSessionUserId(); if ( - event.status === 'completed' && - !isClientQueueItemOrigin(event.origin) && - (sessionUserId === null || event.user_id === sessionUserId) + !isActive() || + event.status !== 'completed' || + isClientQueueItemOrigin(event.origin) || + (sessionUserId !== null && event.user_id !== sessionUserId) ) { - scheduleInvalidation(); + return; + } + + scheduleInvalidation(); + + if (event.destination === REVEAL_DESTINATION) { + onRevealRequest?.(event.item_id); } }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx index 731cfc2138c..2e786dcf631 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx @@ -1,11 +1,12 @@ import type { GalleryImageItem } from '@features/gallery/core/items'; import { ChakraProvider } from '@chakra-ui/react'; +import { requestGalleryItemReveal } from '@features/gallery/core/selection'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { system } from '@theme/system'; import { act, type ComponentProps } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GalleryUiAdapter } from './GalleryUiContext'; @@ -142,34 +143,32 @@ it('renders the compact gallery status through the localized status-chip key', a expect(document.body.textContent).not.toContain('widgets.gallery.statusChip'); }); -it('exposes sparse ranking pages to item context-menu actions', async () => { - const deepItem = createImageItem('deep-result'); - const queryClient = new QueryClient(); - mocks.galleryData = { - boards: [], - boardsState: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, - filter: { boardId: 'none' }, - isLoadingItems: false, - isWindowTruncated: false, - items: [deepItem], - listing: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, - loadMore: noop, - queryError: null, - selectedBoardId: 'none', - setVisibleRange: noop, - sparseListing: { - itemSlots: new Map([[180, deepItem]]), - pageStates: new Map(), - recentItems: [], - total: 181, - }, +const createGalleryData = (deepItem: GalleryImageItem) => ({ + boards: [], + boardsState: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, + filter: { boardId: 'none' }, + isLoadingItems: false, + isWindowTruncated: false, + items: [deepItem], + listing: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, + loadMore: noop, + queryError: null, + selectedBoardId: 'none', + setVisibleRange: noop, + sparseListing: { + itemSlots: new Map([[180, deepItem]]), + pageStates: new Map(), + recentItems: [], total: 181, - }; + }, + total: 181, +}); - await act(() => +const renderGallery = () => + act(() => root?.render( - + @@ -178,5 +177,69 @@ it('exposes sparse ranking pages to item context-menu actions', async () => { ) ); +it('exposes sparse ranking pages to item context-menu actions', async () => { + const deepItem = createImageItem('deep-result'); + mocks.galleryData = createGalleryData(deepItem); + + await renderGallery(); + expect(mocks.getItemActionContext?.()?.getItemSelectionPage?.(deepItem)).toBe(3); }); + +describe('external reveal announcements', () => { + const REVEALED = 'widgets.gallery.externalGenerationRevealed'; + const accountSignal = new AbortController().signal; + /** The failure region first, then the two reveal regions. */ + const regionTexts = () => [...document.body.querySelectorAll('[role="status"]')].map((region) => region.textContent); + const revealExternal = (name: string) => + act(() => requestGalleryItemReveal(`image:${name}`, accountSignal, 0, 'external-generation')); + + beforeEach(() => { + mocks.galleryData = createGalleryData(createImageItem('deep-result')); + }); + + it('announces each external reveal once, and none the user made', async () => { + await renderGallery(); + + await act(() => requestGalleryItemReveal('image:mine.png', accountSignal, 3)); + expect(regionTexts()).toEqual(['', '', '']); + + await revealExternal('first.png'); + const afterFirst = regionTexts(); + expect(afterFirst.filter((text) => text === REVEALED)).toHaveLength(1); + + // The second announcement lands in the region the first left blank, so it is a change and is read again. + await revealExternal('second.png'); + const afterSecond = regionTexts(); + const announcedIn = afterSecond.indexOf(REVEALED); + expect(afterSecond.filter((text) => text === REVEALED)).toHaveLength(1); + expect(afterFirst[announcedIn]).toBe(''); + + await act(() => requestGalleryItemReveal('image:mine.png', accountSignal, 3)); + expect(regionTexts()).toEqual(['', '', '']); + }); + + it('keeps reveal text out of the failure announcement', async () => { + mocks.galleryData = { + ...createGalleryData(createImageItem('deep-result')), + listing: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'stale-error' }, + }; + await renderGallery(); + await revealExternal('external.png'); + + expect(regionTexts()[0]).toBe('widgets.gallery.listingRefreshFailed'); + expect(regionTexts().slice(1)).toContain(REVEALED); + }); + + it('does not repeat an earlier reveal when the Gallery mounts again', async () => { + await renderGallery(); + await revealExternal('external.png'); + expect(regionTexts()).toContain(REVEALED); + + await act(() => root?.unmount()); + root = createRoot(host!); + await renderGallery(); + + expect(regionTexts()).toEqual(['', '', '']); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx index 05c27da9454..f9c7ed8fabc 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx @@ -5,12 +5,13 @@ import type { TFunction } from 'i18next'; import { toGalleryItemKey, toGalleryItemRef } from '@features/gallery/core/items'; import { getBoundedRecentImages } from '@features/gallery/core/recentImages'; +import { getGalleryRevealRequest, subscribeGalleryRevealRequests } from '@features/gallery/core/selection'; import { getGallerySettings } from '@features/gallery/core/settings'; import { GALLERY_PAGE_SIZE, galleryItemNamesOptions } from '@features/gallery/data/queries'; import { StatusWidgetChip } from '@platform/ui'; import { useQueryClient } from '@tanstack/react-query'; import { ImageIcon, TriangleAlertIcon } from 'lucide-react'; -import { useCallback, useEffect, useEffectEvent, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import type { GalleryReadState, GalleryReadStatus, GalleryStateView } from './galleryStateView'; @@ -338,6 +339,13 @@ const GalleryWidgetContent = ({ }) => { const { t } = useTranslation(); const itemActions = useGalleryItemActions(); + const revealRequest = useSyncExternalStore(subscribeGalleryRevealRequests, getGalleryRevealRequest); + // Reveals requested before this mount were announced by an earlier one, or by none; a remount must not repeat them. + const [mountRevealToken] = useState(() => getGalleryRevealRequest()?.token ?? 0); + const revealAnnouncement = + revealRequest?.announcement === 'external-generation' && revealRequest.token > mountRevealToken + ? { message: t('widgets.gallery.externalGenerationRevealed'), token: revealRequest.token } + : null; const projectNames = useMemo(() => new Map(projects.map((project) => [project.id, project.name])), [projects]); const contextValue = useMemo( () => ({ @@ -387,6 +395,16 @@ const GalleryWidgetContent = ({ + {/* + * Each reveal request has its own token. Alternating regions by token parity makes consecutive identical + * announcements a change in an existing region, so each is read; any later reveal request blanks both. + */} + {[0, 1].map((parity) => ( + + ))} ); }; diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts index a0c145fcf4a..b95ebdafb1e 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts @@ -20,6 +20,7 @@ export interface QueueServerItemDTO { field_values?: QueueNodeFieldValueDTO[] | null; item_id: number; origin?: string | null; + /** Set on a workflow-call child; its outputs reach the user through the parent's results. */ parent_item_id?: number | null; priority?: number | null; retried_from_item_id?: number | null; diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts index 05497748a8b..00a293a9099 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts @@ -761,3 +761,71 @@ describe('getResultVideoNames', () => { await expect(getResultVideoNames(1, { resultNodeIds: ['final_l2v'] })).resolves.toEqual(['final.mp4']); }); }); + +describe('readQueueItemLastSavedImageName', () => { + const imageUrl = (imageName: string) => `/api/v1/images/i/${imageName}`; + const serveItem = ( + item: Record, + intermediates: readonly string[], + missing: readonly string[] = [] + ) => + mocks.apiFetchJson.mockImplementation((url: string) => { + if (url === '/api/v1/queue/default/i/1') { + return Promise.resolve({ item_id: 1, status: 'completed', ...item }); + } + + const imageName = url.replace('/api/v1/images/i/', ''); + + if (missing.includes(imageName)) { + return Promise.reject(new ApiError('Not found', 404)); + } + + return Promise.resolve({ image_name: imageName, is_intermediate: intermediates.includes(imageName) }); + }); + const session = { + results: { + first: { image: { image_name: 'first.png' }, type: 'image_output' }, + saved: { image: { image_name: 'saved.png' }, type: 'image_output' }, + deleted: { image: { image_name: 'deleted.png' }, type: 'image_output' }, + preview: { image: { image_name: 'preview.png' }, type: 'image_output' }, + }, + }; + let ApiError: new (message: string, status: number) => Error; + + beforeEach(async () => { + accountLifecycle.activate('test-account'); + mocks.apiFetchJson.mockReset(); + ({ ApiError } = (await import('@platform/transport/http')) as unknown as { ApiError: typeof ApiError }); + }); + + it('walks back from the last output to the first saved image, reading no earlier record', async () => { + serveItem({ session }, ['preview.png'], ['deleted.png']); + const { readQueueItemLastSavedImageName } = await import('./submissionApi'); + + await expect(readQueueItemLastSavedImageName(1)).resolves.toBe('saved.png'); + expect(mocks.apiFetchJson.mock.calls.map(([url]) => url)).not.toContain(imageUrl('first.png')); + }); + + it('reveals nothing for a workflow-call child or an item without saved images', async () => { + const { readQueueItemLastSavedImageName } = await import('./submissionApi'); + + serveItem({ parent_item_id: 7, session }, []); + await expect(readQueueItemLastSavedImageName(1)).resolves.toBeNull(); + + serveItem({ session }, ['first.png', 'saved.png', 'preview.png'], ['deleted.png']); + await expect(readQueueItemLastSavedImageName(1)).resolves.toBeNull(); + }); + + it("passes the caller's abort signal to every read", async () => { + serveItem({ session }, ['preview.png'], ['deleted.png']); + const { readQueueItemLastSavedImageName } = await import('./submissionApi'); + const controller = new AbortController(); + + await readQueueItemLastSavedImageName(1, controller.signal); + controller.abort(); + + for (const [, init] of mocks.apiFetchJson.mock.calls as [string, { signal: AbortSignal }][]) { + expect(init.signal.aborted).toBe(true); + } + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts index 4b22a8d4721..ebbe288f77c 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts @@ -165,6 +165,46 @@ const getResultImageNames = (queueItem: QueueServerItemDTO, options?: QueueResul return [...imageNames]; }; +/** + * The last non-intermediate image a completed backend item saved, by execution order: image records are read from the + * end until one is not an intermediate. Null when it saved none, and for a workflow-call child, whose outputs reach + * the user through its parent's results. + */ +export const readQueueItemLastSavedImageName = async (itemId: number, signal?: AbortSignal): Promise => { + const owner = captureAccountScope(); + const requestSignal = signal ? AbortSignal.any([signal, owner.signal]) : owner.signal; + const item = await getQueueItem(itemId, requestSignal); + + assertAccountScopeCurrent(owner); + + if (item.parent_item_id !== null && item.parent_item_id !== undefined) { + return null; + } + + for (const imageName of getResultImageNames(item).reverse()) { + let image: QueueImageDTO; + + try { + image = await apiFetchJson(`/api/v1/images/i/${encodeURIComponent(imageName)}`, { + signal: requestSignal, + }); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + continue; + } + throw error; + } + + assertAccountScopeCurrent(owner); + + if (!image.is_intermediate) { + return image.image_name; + } + } + + return null; +}; + const getResultImage = async ( imageName: string, queuedAt: string, diff --git a/invokeai/frontend/webv2/src/features/queue/queries.ts b/invokeai/frontend/webv2/src/features/queue/queries.ts index 17509b28bfe..000b2541c9c 100644 --- a/invokeai/frontend/webv2/src/features/queue/queries.ts +++ b/invokeai/frontend/webv2/src/features/queue/queries.ts @@ -1,3 +1,4 @@ /** Expose shell-facing queue queries while keeping widget data hooks private. */ export { getQueueReadModelOptions } from './publicApi'; export { getQueueQueryScope, type QueueJobsScope } from './ui/queueScope'; +export { readQueueItemLastSavedImageName } from './data/submissionApi'; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.lazy.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.lazy.test.ts new file mode 100644 index 00000000000..42be2ab1692 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.lazy.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./revealExternalGeneration', () => { + throw new Error('The external generation reveal must stay behind its dynamic import.'); +}); +vi.mock('./revealGalleryItem', () => { + throw new Error('Gallery reveal must stay behind the external reveal dynamic import.'); +}); +vi.mock('@features/queue/queries', () => { + throw new Error('Queue result reads must stay behind the external reveal dynamic import.'); +}); + +describe('recall and reveal bridge lazy boundary', () => { + it('loads in the editor boot graph without the reveal it attaches', async () => { + await expect(import('./recallParametersBridge')).resolves.toHaveProperty('attachExternalGenerationReveal'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.ts b/invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.ts index 25b94f84d9b..c29bb8090a8 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/recallParametersBridge.ts @@ -2,10 +2,20 @@ import type { SocketHub } from '@platform/transport/socketHub'; import type { WorkbenchCommands, WorkbenchQueries } from '@workbench/workbenchStore'; import type { TFunction } from 'i18next'; +import { captureGalleryNavigation } from '@features/gallery/contracts'; +import { createLogger } from '@platform/logging/logger'; +import { captureAccountScope } from '@platform/state/accountLifecycle'; +import { getProjectWidgetValues } from '@workbench/widgetState'; + import type { PendingRecallEvent, RecallRevealContext, RecallRuntime } from './recallEventRuntime'; import type { createRecallParametersRuntime } from './recallParametersRuntime'; +import type { ExternalGenerationRevealContext, revealExternalGeneration } from './revealExternalGeneration'; import type { createVideoRecallRuntime } from './videoRecallRuntime'; +/* + * Image-action socket bridges kept in the editor boot graph: each loads the runtime it attaches on demand. + */ + /** * Lazy-load a recall runtime on its first event; buffer events in arrival order with their original project. Report * load failures and retry on the next event. This module stays in the editor boot graph, so it imports the runtimes @@ -142,3 +152,98 @@ export const attachVideoRecallRuntime = ({ load, queries, }); + +interface ExternalGenerationRevealModule { + revealExternalGeneration: typeof revealExternalGeneration; +} + +export interface ExternalGenerationRevealer { + dispose(): void; + /** Reveal a backend queue item another client completed with the reveal destination. */ + request(queueItemId: number): void; +} + +/** + * Another client's completed reveal request → the Gallery. Capture the arrival state the reveal is fenced by + * (account, project, Gallery navigation) before the reveal chunk loads, and let only the newest request reveal: each + * arrival aborts the one before it. A request a hidden tab deferred waits, alone, until the tab is shown. + */ +export const attachExternalGenerationReveal = ({ + context, + load = () => import('./revealExternalGeneration'), +}: { + context: ExternalGenerationRevealContext; + load?: () => Promise; +}): ExternalGenerationRevealer => { + type Request = Parameters[1]; + let disposed = false; + let latest: AbortController | null = null; + let deferred: { controller: AbortController; request: Request; sequence: number } | null = null; + + const run = (controller: AbortController, request: Request, sequence?: number): void => { + void load() + .then(({ revealExternalGeneration }) => revealExternalGeneration(context, request, sequence)) + .then((outcome) => { + if (outcome.kind === 'hidden' && latest === controller && !disposed) { + deferred = { controller, request, sequence: outcome.sequence }; + // The tab may have been shown again while this run was settling. + resume(); + } + }) + .catch((error: unknown) => { + // Superseded and disposed reveals fail with their abort; nothing on screen asked for either. + if (!controller.signal.aborted) { + createLogger( + { area: 'external-generation-reveal', namespace: 'gallery', projectId: request.projectId }, + { owner: request.accountScope } + ).warn({ + context: { queueItemId: request.queueItemId }, + error, + message: 'Could not reveal an external generation.', + }); + } + }); + }; + + const resume = (): void => { + if (!deferred || !context.isVisible()) { + return; + } + + const { controller, request, sequence } = deferred; + + deferred = null; + run(controller, request, sequence); + }; + + const detachVisibility = context.onVisibilityChange(resume); + + return { + dispose: () => { + disposed = true; + detachVisibility(); + deferred = null; + latest?.abort(); + latest = null; + }, + request: (queueItemId) => { + if (disposed) { + return; + } + + latest?.abort(); + deferred = null; + const controller = new AbortController(); + const project = context.queries.getSnapshot().activeProject; + + latest = controller; + run(controller, { + accountScope: captureAccountScope(), + navigation: captureGalleryNavigation(getProjectWidgetValues(project, 'gallery')), + projectId: project.id, + queueItemId, + signal: controller.signal, + }); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts new file mode 100644 index 00000000000..6ab50d4ac37 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts @@ -0,0 +1,427 @@ +import type { GalleryItem } from '@features/gallery/core/items'; +import type { QueueItem } from '@features/queue/contracts'; +import type { QueryClient } from '@tanstack/react-query'; +import type { WorkbenchCommands, WorkbenchQueries } from '@workbench/workbenchStore'; + +import { claimGalleryNavigationSequence } from '@features/gallery/contracts'; +import { getGalleryRevealRequest } from '@features/gallery/core/selection'; +import { createGalleryLocatorRequest } from '@features/gallery/queries'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExternalGenerationRevealContext } from './revealExternalGeneration'; + +import { attachExternalGenerationReveal } from './recallParametersBridge'; + +const mocks = vi.hoisted(() => ({ + fetchVerifiedPage: vi.fn(), + readLastSavedImageName: vi.fn<(itemId: number, signal?: AbortSignal) => Promise>(), + resolve: vi.fn(), +})); + +vi.mock('@features/queue/queries', () => ({ readQueueItemLastSavedImageName: mocks.readLastSavedImageName })); +vi.mock('@features/gallery', async (importOriginal) => ({ + ...(await importOriginal>()), + galleryItems: { resolve: mocks.resolve }, +})); +vi.mock('@features/gallery/queries', async (importOriginal) => ({ + ...(await importOriginal>()), + fetchVerifiedGalleryItemPage: (...args: unknown[]) => mocks.fetchVerifiedPage(...args), +})); + +const createItem = (name: string): GalleryItem => ({ + boardId: 'board-1', + category: 'general', + createdAt: '2026-10-15T12:00:00Z', + fullUrl: `/images/${name}`, + height: 64, + isIntermediate: false, + kind: 'image', + name, + starred: false, + thumbnailUrl: `/thumbs/${name}`, + width: 64, +}); + +const verifiedPage = (name: string) => + Promise.resolve({ index: 0, offset: 0, page: { items: [createItem(name)], total: 1 }, total: 1 }); + +/** A promise the test settles; lets a test act while the reveal waits on the backend. */ +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +}; + +let activeProjectId = 'project-1'; +let galleryValues: Record = {}; +let queueItems: Pick[] = []; +let visible = true; +let editingText = false; +const visibilityListeners = new Set<() => void>(); +/** The browser's `visibilitychange`: the tab is shown or hidden. */ +const setVisible = (next: boolean) => { + visible = next; + for (const listener of visibilityListeners) { + listener(); + } +}; +let commands: { + gallery: Record<'selectBoard' | 'selectItem' | 'setPage' | 'setView', ReturnType>; + widgets: { patchValues: ReturnType }; +}; + +const createContext = (): ExternalGenerationRevealContext => ({ + commands: commands as unknown as WorkbenchCommands, + isEditingText: () => editingText, + isVisible: () => visible, + onVisibilityChange: (listener) => { + visibilityListeners.add(listener); + return () => visibilityListeners.delete(listener); + }, + queries: { + getSnapshot: () => ({ + activeProject: { + id: activeProjectId, + queue: { items: queueItems }, + widgetInstances: { gallery: { state: { values: galleryValues }, typeId: 'gallery' } }, + }, + }), + isActiveProject: (projectId: string) => projectId === activeProjectId, + } as unknown as WorkbenchQueries, + queryClient: { fetchQuery: vi.fn().mockResolvedValue([{ id: 'board-1' }]) } as unknown as QueryClient, +}); + +/** + * Attach the composing bridge with the real reveal module (and through it the real Find in Gallery path), recording + * each reveal run so a test can wait for all of them to settle. + */ +const attach = (loadGate: Promise = Promise.resolve()) => { + const runs: Promise[] = []; + const revealer = attachExternalGenerationReveal({ + context: createContext(), + load: async () => { + await loadGate; + const module = await import('./revealExternalGeneration'); + return { + revealExternalGeneration: (...args: Parameters) => { + const run = module.revealExternalGeneration(...args); + runs.push(run.catch(() => undefined)); + return run; + }, + }; + }, + }); + const settle = async (count: number) => { + await vi.waitFor(() => expect(runs).toHaveLength(count)); + await Promise.all(runs); + }; + + return { revealer, settle }; +}; + +const reveal = async (queueItemId = 42) => { + const { revealer, settle } = attach(); + + revealer.request(queueItemId); + await settle(1); + revealer.dispose(); +}; + +const revealed = () => commands.gallery.selectItem.mock.calls.map(([item]) => (item as GalleryItem).name); + +beforeEach(() => { + accountLifecycle.activate('external-reveal-test'); + activeProjectId = 'project-1'; + galleryValues = { selectedBoardId: 'board-0', selectedImageName: 'image:mine.png' }; + queueItems = []; + visible = true; + editingText = false; + visibilityListeners.clear(); + commands = { + gallery: { selectBoard: vi.fn(), selectItem: vi.fn(), setPage: vi.fn(), setView: vi.fn() }, + widgets: { patchValues: vi.fn() }, + }; + mocks.readLastSavedImageName.mockReset().mockResolvedValue('last.png'); + mocks.resolve.mockReset().mockImplementation(({ name }: { name: string }) => Promise.resolve(createItem(name))); + mocks.fetchVerifiedPage + .mockReset() + .mockImplementation((_client: unknown, _filter: unknown, ref: { name: string }) => verifiedPage(ref.name)); +}); + +afterEach(() => accountLifecycle.invalidate()); + +describe('external generation reveal', () => { + it("lands the Gallery on the item's last saved image and its board", async () => { + await reveal(42); + + expect(mocks.readLastSavedImageName).toHaveBeenCalledWith(42, expect.any(AbortSignal)); + expect(commands.gallery.selectBoard).toHaveBeenCalledWith('board-1'); + expect(revealed()).toEqual(['last.png']); + expect(getGalleryRevealRequest()).toMatchObject({ + announcement: 'external-generation', + itemKey: 'image:last.png', + }); + }); + + it.each([ + ['the user is typing', () => (editingText = true)], + ['a run of their own is open', () => (queueItems = [{ status: 'running' }])], + [ + 'the user has several items selected', + () => (galleryValues = { ...galleryValues, selectedImageNames: ['image:a.png', 'image:b.png'] }), + ], + ['a text search narrows the listing', () => (galleryValues = { ...galleryValues, searchTerm: 'cat' })], + ['a semantic query narrows the listing', () => (galleryValues = { ...galleryValues, semanticSearchText: 'cat' })], + [ + 'a similarity query narrows the listing', + () => (galleryValues = { ...galleryValues, semanticImageQuery: { kind: 'text', query: 'cat' } }), + ], + ])('leaves the Gallery alone, reading no results, when %s', async (_, arrange) => { + arrange(); + + await reveal(); + + expect(mocks.readLastSavedImageName).not.toHaveBeenCalled(); + expect(revealed()).toEqual([]); + }); + + it('never supersedes a Find in Gallery still locating its item', async () => { + const find = createGalleryLocatorRequest(); + + await reveal(); + + expect(find.signal.aborted).toBe(false); + expect(revealed()).toEqual([]); + find.release(); + }); + + it.each([ + ['selects another image', () => (galleryValues = { ...galleryValues, selectedImageName: 'image:other.png' })], + ['switches boards', () => (galleryValues = { ...galleryValues, selectedBoardId: 'board-2' })], + ['starts a Find in Gallery', () => claimGalleryNavigationSequence()], + ['starts typing', () => (editingText = true)], + ['starts a run of their own', () => (queueItems = [{ status: 'pending' }])], + ['switches projects', () => (activeProjectId = 'project-2')], + ])('makes no Gallery change when the user %s while the result is read', async (_, act) => { + mocks.readLastSavedImageName.mockImplementation(() => { + act(); + return Promise.resolve('last.png'); + }); + + await reveal(); + + expect(commands.gallery.selectBoard).not.toHaveBeenCalled(); + expect(revealed()).toEqual([]); + }); + + it.each([ + ['selects another image', () => (galleryValues = { ...galleryValues, selectedImageName: 'image:other.png' })], + ['starts a Find in Gallery', () => claimGalleryNavigationSequence()], + ['starts typing', () => (editingText = true)], + ['starts a run of their own', () => (queueItems = [{ status: 'pending' }])], + ])('makes no Gallery change when the user %s while the item is located', async (_, act) => { + mocks.fetchVerifiedPage.mockImplementation((_client: unknown, _filter: unknown, ref: { name: string }) => { + act(); + return verifiedPage(ref.name); + }); + + await reveal(); + + expect(mocks.fetchVerifiedPage).toHaveBeenCalled(); + expect(commands.gallery.selectBoard).not.toHaveBeenCalled(); + expect(commands.widgets.patchValues).not.toHaveBeenCalled(); + expect(revealed()).toEqual([]); + }); + + it('does not reveal once the account that received the completion is gone', async () => { + mocks.readLastSavedImageName.mockImplementation(() => { + accountLifecycle.invalidate(); + return Promise.resolve('last.png'); + }); + + await reveal(); + + expect(revealed()).toEqual([]); + }); + + it('reveals nothing for an item without a saved image, such as a workflow-call child', async () => { + mocks.readLastSavedImageName.mockResolvedValue(null); + + await reveal(); + + expect(revealed()).toEqual([]); + }); + + it('lets only the newest of a burst of completions reveal, even when an older read finishes last', async () => { + const reads = new Map([ + [1, deferred()], + [2, deferred()], + ]); + mocks.readLastSavedImageName.mockImplementation((itemId) => reads.get(itemId)!.promise); + const { revealer, settle } = attach(); + + revealer.request(1); + await vi.waitFor(() => expect(mocks.readLastSavedImageName).toHaveBeenCalledTimes(1)); + revealer.request(2); + await vi.waitFor(() => expect(mocks.readLastSavedImageName).toHaveBeenCalledTimes(2)); + reads.get(2)!.resolve('newest.png'); + await vi.waitFor(() => expect(revealed()).toEqual(['newest.png'])); + reads.get(1)!.resolve('older.png'); + await settle(2); + + expect(revealed()).toEqual(['newest.png']); + revealer.dispose(); + }); + + it('drops a superseded completion before reading its results', async () => { + const { revealer, settle } = attach(); + + revealer.request(1); + revealer.request(2); + await settle(2); + + expect(mocks.readLastSavedImageName.mock.calls.map(([itemId]) => itemId)).toEqual([2]); + revealer.dispose(); + }); + + it('drops a reveal still in flight when the runtime is disposed', async () => { + const read = deferred(); + mocks.readLastSavedImageName.mockReturnValue(read.promise); + const { revealer, settle } = attach(); + + revealer.request(42); + await vi.waitFor(() => expect(mocks.readLastSavedImageName).toHaveBeenCalled()); + revealer.dispose(); + read.resolve('last.png'); + await settle(1); + + expect(mocks.readLastSavedImageName.mock.calls[0]![1]!.aborted).toBe(true); + expect(revealed()).toEqual([]); + }); + + it('reveals through an open but empty semantic field, which Find in Gallery then closes', async () => { + galleryValues = { ...galleryValues, semanticImageQuery: null, semanticSearchText: '' }; + + await reveal(); + + expect(revealed()).toEqual(['last.png']); + expect(commands.widgets.patchValues).toHaveBeenCalledWith( + 'gallery', + expect.objectContaining({ semanticSearchText: null }) + ); + }); + + it('fences the reveal by the navigation at arrival, even while its chunk is still loading', async () => { + const loading = deferred(); + const { revealer, settle } = attach(loading.promise); + + revealer.request(42); + galleryValues = { ...galleryValues, selectedImageName: 'image:other.png' }; + loading.resolve(); + await settle(1); + + expect(mocks.readLastSavedImageName).not.toHaveBeenCalled(); + expect(revealed()).toEqual([]); + revealer.dispose(); + }); + + it.each([ + ['galleryPage', 3], + ['galleryView', 'assets'], + ['searchTerm', ''], + ['selectedBoardId', 'board-2'], + ['selectedImageName', 'image:other.png'], + ['selectedImageNames', ['image:other.png']], + ['semanticSearchText', ''], + ['starredOnly', true], + ])('treats a change to %s after arrival as the user navigating', async (key, value) => { + mocks.readLastSavedImageName.mockImplementation(() => { + galleryValues = { ...galleryValues, [key]: value }; + return Promise.resolve('last.png'); + }); + + await reveal(); + + expect(revealed()).toEqual([]); + }); + + describe('in a hidden tab', () => { + it('waits until the tab is shown, then reveals', async () => { + visible = false; + const { revealer, settle } = attach(); + + revealer.request(42); + await settle(1); + + expect(mocks.readLastSavedImageName).not.toHaveBeenCalled(); + + setVisible(true); + await settle(2); + + expect(revealed()).toEqual(['last.png']); + revealer.dispose(); + }); + + it('resumes a reveal the tab hid while its item was being located', async () => { + mocks.fetchVerifiedPage.mockImplementationOnce((_client: unknown, _filter: unknown, ref: { name: string }) => { + visible = false; + return verifiedPage(ref.name); + }); + const { revealer, settle } = attach(); + + revealer.request(42); + await settle(1); + + expect(revealed()).toEqual([]); + + setVisible(true); + await settle(2); + + expect(revealed()).toEqual(['last.png']); + revealer.dispose(); + }); + + it('keeps only the newest request waiting', async () => { + visible = false; + const { revealer, settle } = attach(); + + revealer.request(1); + revealer.request(2); + await settle(2); + setVisible(true); + await settle(3); + + expect(mocks.readLastSavedImageName.mock.calls.map(([itemId]) => itemId)).toEqual([2]); + revealer.dispose(); + }); + + it('drops a waiting request whose user navigated before the tab was shown', async () => { + visible = false; + const { revealer, settle } = attach(); + + revealer.request(42); + await settle(1); + galleryValues = { ...galleryValues, selectedBoardId: 'board-2' }; + setVisible(true); + await settle(2); + + expect(revealed()).toEqual([]); + revealer.dispose(); + }); + + it('stops listening for the tab, and drops the waiting request, on dispose', async () => { + visible = false; + const { revealer, settle } = attach(); + + revealer.request(42); + await settle(1); + revealer.dispose(); + + expect(visibilityListeners.size).toBe(0); + expect(mocks.readLastSavedImageName).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.ts new file mode 100644 index 00000000000..4806f65b10d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.ts @@ -0,0 +1,116 @@ +import type { GalleryNavigationSnapshot } from '@features/gallery/contracts'; +import type { AccountScope } from '@platform/state/accountLifecycle'; + +import { + claimGalleryNavigationSequence, + getPersistedSelectedGalleryItemKeys, + hasGalleryListingSearch, + hasGalleryLocatorRequests, + isGalleryNavigationCurrent, + isGalleryNavigationStateUnchanged, +} from '@features/gallery/contracts'; +import { isOpenQueueItem } from '@features/queue/contracts'; +import { readQueueItemLastSavedImageName } from '@features/queue/queries'; +import { getProjectWidgetValues } from '@workbench/widgetState'; + +import type { GalleryRevealContext } from './revealGalleryItem'; + +import { revealGalleryItem } from './revealGalleryItem'; + +export interface ExternalGenerationRevealContext extends GalleryRevealContext { + /** Whether the user is typing: this window has focus and a text field in it does. */ + isEditingText: () => boolean; + /** Whether this tab is on screen; a hidden tab defers its reveal until it is shown. */ + isVisible: () => boolean; + /** Calls `listener` whenever the tab is shown or hidden; returns the unsubscribe. */ + onVisibilityChange: (listener: () => void) => () => void; +} + +/** Captured when the completion arrives, before the reveal chunk loads. */ +export interface ExternalGenerationRevealRequest { + accountScope: AccountScope; + /** The Gallery navigation at arrival; any change the user makes after it stands the reveal down. */ + navigation: GalleryNavigationSnapshot; + /** The project on screen at arrival; the reveal never lands in another. */ + projectId: string; + /** The backend queue item that completed. */ + queueItemId: number; + /** Aborted when a newer completion supersedes this one or the composing runtime is disposed. */ + signal: AbortSignal; +} + +export type ExternalGenerationRevealOutcome = + /** Revealed, or stood down for good. */ + | { kind: 'settled' } + /** Stood down only because the tab is hidden; resume from `sequence` once it is visible again. */ + | { kind: 'hidden'; sequence: number }; + +const SETTLED: ExternalGenerationRevealOutcome = { kind: 'settled' }; + +/** + * Reveal another client's completed generation, as Find in Gallery would: select the board of its last saved image, + * then the image, and announce it. It never takes the Gallery from the user. It stands down once a newer completion + * arrives, while the user types in the focused window, while a run of their own in this project is open, while a + * search or semantic query narrows the listing, while several items are selected, while another reveal is locating + * its item, and once the user navigates the Gallery after arrival; all of these are dropped. A hidden tab only defers + * it: the outcome asks to resume once the tab is visible. Every condition is checked again immediately before the + * first Gallery change. + * + * `ownedSequence` is the latest navigation claim the reveal may treat as its own: the one at arrival, or on resuming + * the one it claimed before the tab was hidden. + */ +export const revealExternalGeneration = async ( + context: ExternalGenerationRevealContext, + request: ExternalGenerationRevealRequest, + ownedSequence: number = request.navigation.sequence +): Promise => { + const { isEditingText, isVisible, queries } = context; + const { accountScope, navigation, projectId, queueItemId, signal } = request; + let owned = ownedSequence; + // Every condition but visibility. A hidden tab cannot change the Gallery, so the arrival snapshot stays valid. + const mayApplyWhenVisible = (): boolean => { + if (signal.aborted || accountScope.signal.aborted || !queries.isActiveProject(projectId)) { + return false; + } + + const project = queries.getSnapshot().activeProject; + const values = getProjectWidgetValues(project, 'gallery'); + + return ( + !isEditingText() && + !project.queue.items.some(isOpenQueueItem) && + !hasGalleryListingSearch(values) && + getPersistedSelectedGalleryItemKeys(values).length <= 1 && + !hasGalleryLocatorRequests() && + isGalleryNavigationStateUnchanged(navigation, values) + ); + }; + const mayApply = (): boolean => isVisible() && mayApplyWhenVisible(); + // Any navigation claimed since the one this reveal owns is the user's. + const isOwned = (): boolean => isGalleryNavigationCurrent(owned); + const standDown = (): ExternalGenerationRevealOutcome => + !isVisible() && isOwned() && mayApplyWhenVisible() ? { kind: 'hidden', sequence: owned } : SETTLED; + + if (!isOwned() || !mayApply()) { + return standDown(); + } + + const imageName = await readQueueItemLastSavedImageName(queueItemId, signal); + + if (imageName === null) { + return SETTLED; + } + if (!isOwned() || !mayApply()) { + return standDown(); + } + + owned = claimGalleryNavigationSequence(); + await revealGalleryItem( + context, + { kind: 'image', name: imageName }, + { accountScope, announcement: 'external-generation', locatorSignal: signal, mayApply, projectId, sequence: owned } + ); + + // Revealing changes the Gallery's navigation, so only a reveal the tab's hiding stood down can resume. + return standDown(); +}; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts index 41c87d4d74f..d0bbd855803 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts @@ -130,6 +130,24 @@ describe('revealGalleryItem', () => { expect(mocks.requestReveal).toHaveBeenCalledWith('image:target.png', revealTicket.accountScope.signal, 127); }); + it("stands down before any Gallery change when the ticket's own condition fails once the page is verified", async () => { + let allowed = true; + mocks.fetchVerifiedPage.mockImplementation(() => { + allowed = false; + return Promise.resolve({ index: 127, offset: 120, page: { items: [item], total: 200 }, total: 200 }); + }); + mocks.galleryValues = { searchTerm: 'sunset' }; + const { commands, context } = createContext(); + + await revealGalleryItem(context, { kind: 'image', name: item.name }, { ...ticket(), mayApply: () => allowed }); + + expect(mocks.fetchVerifiedPage).toHaveBeenCalledOnce(); + expect(commands.widgets.patchValues).not.toHaveBeenCalled(); + expect(commands.gallery.selectBoard).not.toHaveBeenCalled(); + expect(commands.gallery.selectItem).not.toHaveBeenCalled(); + expect(mocks.requestReveal).not.toHaveBeenCalled(); + }); + it('keeps selection, filters, board, and scroll unchanged when the one locator retry cannot verify a page', async () => { mocks.fetchVerifiedPage.mockResolvedValue(null); mocks.galleryValues = { searchTerm: 'sunset', semanticImageQuery: { kind: 'text', query: 'sunset' } }; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts index 7915f22d8c8..b7af3c7d94a 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts @@ -1,5 +1,5 @@ import type { GalleryView } from '@features/gallery'; -import type { GalleryItemRef } from '@features/gallery/contracts'; +import type { GalleryItemRef, GalleryRevealAnnouncement } from '@features/gallery/contracts'; import type { AccountScope } from '@platform/state/accountLifecycle'; import type { QueryClient } from '@tanstack/react-query'; import type { WorkbenchCommands, WorkbenchQueries } from '@workbench/workbenchStore'; @@ -25,8 +25,15 @@ export interface GalleryRevealContext { export interface GalleryRevealTicket { /** Account identity lifetime at the original gesture, before any lazy import or media fetch. */ accountScope: AccountScope; + /** Announced in the Gallery's live region once the item is revealed; reveals the user asked for set none. */ + announcement?: GalleryRevealAnnouncement; /** Canceled when a later Gallery navigation supersedes this locator. */ locatorSignal?: AbortSignal; + /** + * A further condition checked wherever the ticket is, last immediately before the first Gallery change; false + * stands the reveal down. For reveals nobody on screen asked for. + */ + mayApply?: () => boolean; /** The project the press belongs to; its writes may not land in another. */ projectId: string; /** This navigation's place in the global ordering; see `claimGalleryNavigationSequence`. */ @@ -41,11 +48,14 @@ export interface GalleryRevealTicket { export const revealGalleryItem = ( { commands, queries, queryClient }: GalleryRevealContext, ref: GalleryItemRef, - { accountScope, locatorSignal, projectId, sequence }: GalleryRevealTicket + { accountScope, announcement, locatorSignal, mayApply, projectId, sequence }: GalleryRevealTicket ): Promise => { // Fence the network result to the gesture's project before clearing filters or selecting. const isCurrent = () => - !accountScope.signal.aborted && isGalleryNavigationCurrent(sequence) && queries.isActiveProject(projectId); + !accountScope.signal.aborted && + isGalleryNavigationCurrent(sequence) && + queries.isActiveProject(projectId) && + (mayApply?.() ?? true); if (!isCurrent()) { return Promise.resolve(); @@ -147,7 +157,9 @@ export const revealGalleryItem = ( } commands.gallery.selectItem(image, projectId, page ?? undefined); - if (boardIndex === null) { + if (announcement !== undefined) { + requestGalleryItemReveal(toGalleryItemKey(ref), accountScope.signal, boardIndex ?? undefined, announcement); + } else if (boardIndex === null) { requestGalleryItemReveal(toGalleryItemKey(ref), accountScope.signal); } else { requestGalleryItemReveal(toGalleryItemKey(ref), accountScope.signal, boardIndex); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx index 2c700924a24..1722d17f337 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ selectBoard: vi.fn(), selectItem: vi.fn(), locatorControllers: [] as AbortController[], + locatorReleases: [] as ReturnType[], setPage: vi.fn(), settings: { imageOrderDir: 'DESC', paginationMode: 'paginated' } as Record, setView: vi.fn(), @@ -42,9 +43,11 @@ vi.mock('@features/gallery/utility', () => ({ }, createGalleryLocatorRequest: () => { const controller = new AbortController(); + const release = vi.fn(); mocks.locatorControllers.push(controller); + mocks.locatorReleases.push(release); - return { signal: controller.signal, release: vi.fn() }; + return { signal: controller.signal, release }; }, })); @@ -161,6 +164,7 @@ beforeEach(() => { mocks.galleryValues = {}; mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'paginated' }; mocks.locatorControllers.length = 0; + mocks.locatorReleases.length = 0; // Empty boards read as "still loading" — the reveal gives the board the // benefit of the doubt, matching the gallery's own fallback rules. mocks.fetchBoards.mockResolvedValue([]); @@ -564,6 +568,22 @@ describe('useMapSelection', () => { expect(mocks.requestReveal).not.toHaveBeenCalled(); }); + it('holds a locator request while its primary image hydrates, so no unrequested reveal supersedes it', async () => { + const pending = deferred<{ boardId: string; category: string; kind: string; name: string }>(); + mocks.resolve.mockReturnValueOnce(pending.promise); + await mount(); + + await flush(() => handle.clickCluster?.({ kind: 'image', name: 'a.png' }, ['image:a.png'], 'nearby')); + + expect(mocks.locatorReleases).toHaveLength(1); + expect(mocks.locatorReleases[0]).not.toHaveBeenCalled(); + + await flush(() => pending.resolve({ boardId: 'board-a', category: 'general', kind: 'image', name: 'a.png' })); + + expect(mocks.selectItem).toHaveBeenCalledOnce(); + expect(mocks.locatorReleases[0]).toHaveBeenCalledOnce(); + }); + it('retires a cluster click still hydrating when its account epoch changes', async () => { accountLifecycle.activate('gallery-cluster-test'); const pending = deferred<{ boardId: string; category: string; kind: string; name: string }>(); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts index 6829c96f789..3765b079e14 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts @@ -60,6 +60,8 @@ export const useMapSelection = (): MapSelectionActions => { const projectId = queries.getSnapshot().activeProject.id; const sequence = claimGalleryNavigationSequence(); abortGalleryLocatorRequests(); + // Registered while the primary item hydrates, so a reveal nobody asked for cannot supersede this click. + const locatorRequest = createGalleryLocatorRequest(); galleryItems .resolve(primaryItem) @@ -93,7 +95,8 @@ export const useMapSelection = (): MapSelectionActions => { }) .catch(() => { // Selection is simply left unchanged on hydrate failure. - }); + }) + .finally(locatorRequest.release); }, [commands, queries] );