From 6748adfca88a12ff4929c69ce84209947617a394 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 2 Aug 2026 17:11:24 -0400 Subject: [PATCH 1/3] feat(ui): image map cluster selection mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PhotoMapAI's cluster-click behavior as an opt-in mode: - A header toggle (persisted in the widget's values) switches map clicks between selecting one image and selecting the clicked point's whole DBSCAN cluster. - Cluster clicks multi-select the cluster in the gallery via setGalleryMultiSelection, ordered by distance from the clicked point (proximity ordering) and capped at 5000 members; the clicked image becomes the primary selection, so Preview follows it. Noise points fall back to single selection. - The gallery's multi-selection renders on the map as a "Highlighted Points" trace — larger, white-outlined, between the base points and the gold current-image marker — regardless of where the selection was made. Amended during the rebase onto the re-architected webv2: - The header toggle ships through the widget implementation's `headerActions` slot (the old single-manifest index.ts is gone) and patches its persisted value via `useWorkbenchCommands().widgets .patchValues('image-map', ...)` instead of dispatching `patchWidgetValues`; IconButton/Tooltip come from `@platform/ui` and `WidgetViewProps` from `@workbench/widgetContracts`. - Cluster clicks now go through `useWorkbenchCommands().gallery .setItemMultiSelection(itemKeys, primaryItem)`: the ordered names are wrapped as kind-tagged keys with `toGalleryItemKey({ kind: 'image', name })` (map points are always images) and the clicked image is hydrated through `galleryImages.resolveMany` + `legacyGeneratedImageToGalleryItem`, sharing the single-select path's monotonic click sequence. Single select (the plain-click clear) is unchanged: `gallery.selectItem` already collapses the multi-selection. - The highlight overlay reads the persisted selection with `getPersistedSelectedGalleryItemKeys` and parses the kind-tagged keys back to bare image names (`parseGalleryItemKey`, images only) — on this architecture `selectedImageNames` stores GalleryItemKeys, not names. - Performance baselines recaptured (build + browser, browser verified passing twice): the image-map implementation chunk loads at editor boot via the singleton host, so the header toggle and cluster wiring grow editor owned bytes by 65 (launchpad owned -3 minifier drift). Co-Authored-By: Claude Fable 5 --- .../image-map/clusterSelection.test.ts | 50 +++++++++ .../workbench/image-map/clusterSelection.ts | 32 ++++++ .../src/workbench/image-map/imageMapTraces.ts | 31 ++++++ .../image-map/ImageMapHeaderActions.tsx | 36 +++++++ .../widgets/image-map/ImageMapPlot.tsx | 102 +++++++++++++++--- .../widgets/image-map/ImageMapWidgetView.tsx | 4 +- .../widgets/image-map/implementation.ts | 2 + .../useSelectMapImage.browser.test.tsx | 11 +- .../widgets/image-map/useSelectMapImage.ts | 55 ++++++++-- 9 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx diff --git a/invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.test.ts b/invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.test.ts new file mode 100644 index 00000000000..1e3489b4ae0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import type { ImageMapPoint } from './api'; + +import { collectClusterSelection } from './clusterSelection'; +import { buildHighlightedPointsTrace, HIGHLIGHTED_POINTS_TRACE } from './imageMapTraces'; + +const point = (imageName: string, x: number, y: number, cluster: number): ImageMapPoint => ({ + cluster, + imageName, + x, + y, +}); + +const POINTS = [ + point('a.png', 0, 0, 0), + point('b.png', 3, 0, 0), + point('c.png', 1, 0, 0), + point('other.png', 50, 50, 1), + point('noise.png', -50, 50, -1), +]; + +describe('collectClusterSelection', () => { + it('returns the clicked cluster ordered by distance from the click', () => { + expect(collectClusterSelection(POINTS, 'b.png')).toEqual(['b.png', 'c.png', 'a.png']); + }); + + it('returns null for noise points and unknown names', () => { + expect(collectClusterSelection(POINTS, 'noise.png')).toBeNull(); + expect(collectClusterSelection(POINTS, 'missing.png')).toBeNull(); + }); + + it('caps oversized clusters, keeping the nearest members', () => { + const capped = collectClusterSelection(POINTS, 'a.png', 2); + expect(capped).toEqual(['a.png', 'c.png']); + }); +}); + +describe('buildHighlightedPointsTrace', () => { + it('draws only multi-selections, larger and outlined', () => { + const single = buildHighlightedPointsTrace(POINTS, new Set(['a.png'])); + expect(single.x).toEqual([]); + + const multi = buildHighlightedPointsTrace(POINTS, new Set(['a.png', 'c.png'])); + expect(multi.name).toBe(HIGHLIGHTED_POINTS_TRACE); + expect(multi.customdata).toEqual(['a.png', 'c.png']); + expect(multi.marker.size).toBe(8); + expect(multi.marker.line).toEqual({ color: '#FFFFFF', width: 1 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.ts b/invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.ts new file mode 100644 index 00000000000..cbd1fdcd0a6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-map/clusterSelection.ts @@ -0,0 +1,32 @@ +import type { ImageMapPoint } from './api'; + +/** + * Cluster-click selection: everything in the clicked point's cluster, + * ordered by distance from the clicked point (PhotoMapAI's proximity + * ordering) so gallery multi-selection walks outward from the click. + */ + +/** Selection size guard; DBSCAN can put most of a huge gallery in one cluster. */ +export const MAX_CLUSTER_SELECTION = 5000; + +export const collectClusterSelection = ( + points: ImageMapPoint[], + clickedImageName: string, + cap: number = MAX_CLUSTER_SELECTION +): string[] | null => { + const clicked = points.find((point) => point.imageName === clickedImageName); + + if (!clicked || clicked.cluster < 0) { + return null; + } + + return points + .filter((point) => point.cluster === clicked.cluster) + .map((point) => ({ + distance: (point.x - clicked.x) ** 2 + (point.y - clicked.y) ** 2, + name: point.imageName, + })) + .sort((left, right) => left.distance - right.distance) + .slice(0, cap) + .map((entry) => entry.name); +}; diff --git a/invokeai/frontend/webv2/src/workbench/image-map/imageMapTraces.ts b/invokeai/frontend/webv2/src/workbench/image-map/imageMapTraces.ts index 498089522f2..48f767c95b7 100644 --- a/invokeai/frontend/webv2/src/workbench/image-map/imageMapTraces.ts +++ b/invokeai/frontend/webv2/src/workbench/image-map/imageMapTraces.ts @@ -13,6 +13,7 @@ import { getClusterColor } from './clusterPalette'; */ export const ALL_POINTS_TRACE = 'All Points'; +export const HIGHLIGHTED_POINTS_TRACE = 'Highlighted Points'; export const CURRENT_IMAGE_TRACE = 'Current Image'; /** Opacity for DBSCAN noise points (cluster -1); clustered points are solid. */ @@ -52,6 +53,36 @@ export const buildAllPointsTrace = (points: ImageMapPoint[]): ScatterTrace => ({ y: points.map((point) => point.y), }); +/** + * The gallery's multi-selection (e.g. a cluster click), drawn larger with a + * white outline over the base points. Empty when fewer than two images are + * selected — a single selection is already marked by the gold target. + */ +export const buildHighlightedPointsTrace = ( + points: ImageMapPoint[], + selectedNames: ReadonlySet +): ScatterTrace => { + const selected = selectedNames.size >= 2 ? points.filter((point) => selectedNames.has(point.imageName)) : []; + + return { + customdata: selected.map((point) => point.imageName), + // 'skip': highlighted points sit over their base points, which carry the + // same customdata — hit-testing should fall through to them. + hoverinfo: 'skip', + marker: { + color: selected.map((point) => getClusterColor(point.cluster)), + line: { color: '#FFFFFF', width: 1 }, + opacity: 1, + size: 8, + }, + mode: 'markers', + name: HIGHLIGHTED_POINTS_TRACE, + type: 'scattergl', + x: selected.map((point) => point.x), + y: selected.map((point) => point.y), + }; +}; + /** * The gold target marking the current gallery image. Built empty here; a later * PR restyles it live as the selection changes. diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx new file mode 100644 index 00000000000..35654c45ddd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx @@ -0,0 +1,36 @@ +import type { WidgetViewProps } from '@workbench/widgetContracts'; + +import { Icon } from '@chakra-ui/react'; +import { IconButton, Tooltip } from '@platform/ui'; +import { useWidgetValuesSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; +import { GroupIcon } from 'lucide-react'; +import { useCallback } from 'react'; + +/** + * Header toggle for cluster-selection mode: when on, clicking a map point + * selects its whole cluster in the gallery instead of just the one image. + * Persisted in the widget's values, like the gallery's own view settings. + */ +export const ImageMapHeaderActions = (_props: WidgetViewProps) => { + const { widgets } = useWorkbenchCommands(); + const clickSelectsCluster = useWidgetValuesSelector('image-map', (values) => Boolean(values.clickSelectsCluster)); + const handleToggle = useCallback( + () => widgets.patchValues('image-map', { clickSelectsCluster: !clickSelectsCluster }), + [clickSelectsCluster, widgets] + ); + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapPlot.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapPlot.tsx index 734a059dbd1..9caa4d2dc50 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapPlot.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapPlot.tsx @@ -3,14 +3,21 @@ import type { AxisRanges } from '@workbench/image-map/imageMapViewport'; import type { PlotlyHTMLElement } from 'plotly.js'; import { Box } from '@chakra-ui/react'; -import { getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; +import { + getPersistedSelectedGalleryItemKeys, + getSelectedGalleryImageFromValues, + parseGalleryItemKey, +} from '@features/gallery/contracts'; import { attachWheelZoom } from '@workbench/image-map/attachWheelZoom'; +import { collectClusterSelection } from '@workbench/image-map/clusterSelection'; import { imageMapStore } from '@workbench/image-map/imageMapStore'; import { buildAllPointsTrace, buildCurrentImageTrace, + buildHighlightedPointsTrace, buildMapLayout, CURRENT_IMAGE_TRACE, + HIGHLIGHTED_POINTS_TRACE, } from '@workbench/image-map/imageMapTraces'; import { computePercentileRanges, @@ -19,11 +26,11 @@ import { rangesToKeepMarkerInView, } from '@workbench/image-map/imageMapViewport'; import { getThumbnailUrl } from '@workbench/image-map/thumbnailCache'; -import { useWidgetValuesSelector } from '@workbench/WorkbenchContext'; +import { shallowEqual, useWidgetValuesSelector } from '@workbench/WorkbenchContext'; import Plotly from 'plotly.js-gl2d-dist-min'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { useSelectMapImage } from './useSelectMapImage'; +import { useMapSelection } from './useSelectMapImage'; /** Suppress the synthetic click plotly fires when a pinch gesture ends. */ const PINCH_CLICK_SUPPRESS_MS = 500; @@ -110,14 +117,29 @@ const findTraceIndex = (plot: PlotElement, name: string): number => * module is lazy-loaded so the plotly bundle stays out of the app's critical * path. */ -const ImageMapPlot = () => { +const ImageMapPlot = ({ clickSelectsCluster = false }: { clickSelectsCluster?: boolean }) => { const containerRef = useRef(null); const points = imageMapStore.useSelector((snapshot) => snapshot.data?.points ?? null); const selectedImageName = useWidgetValuesSelector( 'gallery', (values) => getSelectedGalleryImageFromValues(values)?.imageName ?? null ); - const selectImage = useSelectMapImage(); + // The persisted selection stores kind-tagged item keys; map points carry + // bare image names, so parse the keys back down (videos never plot). + const selectedImageNames = useWidgetValuesSelector( + 'gallery', + (values) => + getPersistedSelectedGalleryItemKeys(values) + .map(parseGalleryItemKey) + .filter((ref) => ref.kind === 'image') + .map((ref) => ref.name), + shallowEqual + ); + const selectedNames = useMemo(() => new Set(selectedImageNames), [selectedImageNames]); + const { selectCluster, selectImage } = useMapSelection(); + // Bumped after every scene rebuild so the overlay effects (marker, + // highlight) re-apply onto the fresh, empty overlay traces. + const [plotRevision, setPlotRevision] = useState(0); const lastPinchAtRef = useRef(0); const lastMapSelectionRef = useRef<{ name: string; at: number } | null>(null); // The initial whole-map fit must happen exactly once per mount, at the @@ -126,12 +148,14 @@ const ImageMapPlot = () => { const initialFitDoneRef = useRef(false); const pointsRef = useRef(points); const selectedImageNameRef = useRef(selectedImageName); + const clusterModeRef = useRef(clickSelectsCluster); // Declared before the effects below so the refs are fresh when they run. useEffect(() => { pointsRef.current = points; selectedImageNameRef.current = selectedImageName; - }, [points, selectedImageName]); + clusterModeRef.current = clickSelectsCluster; + }, [clickSelectsCluster, points, selectedImageName]); const [pendingHoverPreview, setHoverPreview] = useState(null); const hoverTimerRef = useRef | null>(null); // Monotonic hover session: a resolution from a previous hover (even of the @@ -154,7 +178,13 @@ const ImageMapPlot = () => { return; } - const traces = [buildAllPointsTrace(points), buildCurrentImageTrace()]; + // Overlay traces (highlight, marker) start empty; the overlay effects + // below restyle them, so a selection change never rebuilds the scene. + const traces = [ + buildAllPointsTrace(points), + buildHighlightedPointsTrace(points, new Set()), + buildCurrentImageTrace(), + ]; let disposed = false; // Feed the CURRENT view back into react so data refreshes never reset the @@ -197,12 +227,22 @@ const ImageMapPlot = () => { const imageName = event.points?.[0]?.customdata; - if (typeof imageName === 'string') { - // A selection made by clicking the map must not recenter the map - // under the user's cursor; the marker effect checks this. The - // stamp expires so a stale entry (failed hydrate, re-click of the - // current point) cannot suppress a legitimate future recenter. - lastMapSelectionRef.current = { at: Date.now(), name: imageName }; + if (typeof imageName !== 'string') { + return; + } + + // A selection made by clicking the map must not recenter the map + // under the user's cursor; the marker effect checks this. The + // stamp expires so a stale entry (failed hydrate, re-click of the + // current point) cannot suppress a legitimate future recenter. + lastMapSelectionRef.current = { at: Date.now(), name: imageName }; + + const clusterNames = clusterModeRef.current ? collectClusterSelection(points, imageName) : null; + + if (clusterNames) { + selectCluster(imageName, clusterNames); + } else { + // Also the cluster-mode fallback for noise points (cluster -1). selectImage(imageName); } }); @@ -235,6 +275,7 @@ const ImageMapPlot = () => { plot.on('plotly_unhover', () => { clearHover(); }); + setPlotRevision((revision) => revision + 1); }) .catch(() => { if (disposed) { @@ -255,7 +296,36 @@ const ImageMapPlot = () => { return () => { disposed = true; }; - }, [points, selectImage]); + }, [points, selectCluster, selectImage]); + + // Highlight overlay: the gallery's multi-selection, restyled in place. + useEffect(() => { + const container = containerRef.current as PlotElement | null; + + if (!container || points === null) { + return; + } + + const highlightIndex = findTraceIndex(container, HIGHLIGHTED_POINTS_TRACE); + + if (highlightIndex < 0) { + return; + } + + const trace = buildHighlightedPointsTrace(points, selectedNames); + swallow( + Plotly.restyle( + container, + { + customdata: [trace.customdata], + 'marker.color': [trace.marker.color as string[]], + x: [trace.x], + y: [trace.y], + }, + [highlightIndex] + ) + ); + }, [plotRevision, points, selectedNames]); // The timer must not outlive the component; the scene effect used to do // this, but it reruns on every `points` change. @@ -314,7 +384,7 @@ const ImageMapPlot = () => { if (recentered) { swallow(Plotly.relayout(container, { 'xaxis.range': recentered.x, 'yaxis.range': recentered.y })); } - }, [points, selectedImageName]); + }, [plotRevision, points, selectedImageName]); // Custom zoom handlers + container size tracking, attached once for the // plot's lifetime; plotly does not observe its container. diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx index 029b9420bea..516610ceb43 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx @@ -2,6 +2,7 @@ import type { WidgetViewProps } from '@workbench/widgetContracts'; import { Button, Center, Spinner, Stack, Text } from '@chakra-ui/react'; import { ensureImageMapLoaded, imageMapStore, refreshImageMapPoints } from '@workbench/image-map/imageMapStore'; +import { useWidgetValuesSelector } from '@workbench/WorkbenchContext'; import { lazy, Suspense, useEffect } from 'react'; // Lazy so the plotly bundle (its own vite chunk, ~1.5MB) loads only when the @@ -28,6 +29,7 @@ const plotLoadingFallback = ( */ export const ImageMapWidgetView = (_props: WidgetViewProps) => { const { data, error, loadState, renderError } = imageMapStore.useSnapshot(); + const clickSelectsCluster = useWidgetValuesSelector('image-map', (values) => Boolean(values.clickSelectsCluster)); useEffect(() => { ensureImageMapLoaded(); @@ -60,7 +62,7 @@ export const ImageMapWidgetView = (_props: WidgetViewProps) => { // mounted and the spinner where the plot will appear. return ( - + ); } diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/implementation.ts b/invokeai/frontend/webv2/src/workbench/widgets/image-map/implementation.ts index 2491c0d27be..629bfded5f2 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/implementation.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/implementation.ts @@ -1,11 +1,13 @@ import type { WidgetImplementation } from '@workbench/widgetContracts'; import { ImageMapDataRuntime } from './ImageMapDataRuntime'; +import { ImageMapHeaderActions } from './ImageMapHeaderActions'; import { ImageMapWidgetFooter } from './ImageMapWidgetFooter'; import { ImageMapWidgetView } from './ImageMapWidgetView'; export const widgetImplementation = { footer: ImageMapWidgetFooter, + headerActions: ImageMapHeaderActions, host: ImageMapDataRuntime, view: ImageMapWidgetView, } satisfies WidgetImplementation; 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 a438fb54b34..38b2b39a216 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 @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@features/gallery', () => ({ galleryImages: { resolveMany: mocks.resolveMany }, legacyGeneratedImageToGalleryItem: (image: { image_name: string }) => image, + toGalleryItemKey: (key: { kind: string; name: string }) => key, })); vi.mock('@workbench/WorkbenchContext', () => ({ @@ -19,7 +20,7 @@ vi.mock('@workbench/WorkbenchContext', () => ({ }), })); -import { useSelectMapImage } from './useSelectMapImage'; +import { useMapSelection } from './useSelectMapImage'; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -30,11 +31,11 @@ let root: Root | null = null; const handle: { click: ((imageName: string) => void) | null } = { click: null }; const Probe = () => { - const select = useSelectMapImage(); + const { selectImage } = useMapSelection(); useEffect(() => { - handle.click = select; - }, [select]); + handle.click = selectImage; + }, [selectImage]); return null; }; @@ -81,7 +82,7 @@ afterEach(async () => { mocks.selectItem.mockReset(); }); -describe('useSelectMapImage', () => { +describe('useMapSelection', () => { it('dispatches the selection for a click', async () => { mocks.resolveMany.mockResolvedValue([{ image_name: 'a.png' }]); await mount(); 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 688a36c29f9..7d4d5a5c236 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts @@ -1,6 +1,13 @@ -import { galleryImages, legacyGeneratedImageToGalleryItem } from '@features/gallery'; +import { galleryImages, legacyGeneratedImageToGalleryItem, toGalleryItemKey } from '@features/gallery'; import { useWorkbenchCommands } from '@workbench/WorkbenchContext'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; + +export interface MapSelectionActions { + /** Select one image (hydrates the item, then gallery.selectItem). */ + selectImage: (imageName: string) => void; + /** Multi-select a cluster with the clicked image as primary. */ + selectCluster: (primaryImageName: string, imageNames: string[]) => void; +} /** * Module-scoped, deliberately: the thing being guarded — the gallery selection — @@ -12,18 +19,18 @@ import { useCallback } from 'react'; let selectionSequence = 0; /** - * Turns a map point's image name into the current gallery selection. The map - * only knows names; the selection contract wants a full gallery item, so names - * are hydrated through the bulk by-names resolver — always fresh, since a - * cached DTO's star/board state can drift. A monotonic sequence guards rapid - * clicks: only the most recent click may dispatch, so a slow fetch can never - * overwrite a newer selection. Preview follows the gallery selection on its - * own. + * Turns map clicks into gallery selections. The map only knows names; the + * selection contract wants a full gallery item, so names are hydrated through + * the bulk by-names resolver — always fresh, since a cached DTO's star/board + * state can drift. ONE monotonic sequence spans both selection kinds, so + * rapid clicks always resolve to the latest click regardless of which mode + * each went through; a slow fetch can never overwrite a newer selection. + * Preview follows the gallery selection on its own. */ -export const useSelectMapImage = (): ((imageName: string) => void) => { +export const useMapSelection = (): MapSelectionActions => { const commands = useWorkbenchCommands(); - return useCallback( + const selectImage = useCallback( (imageName: string) => { const sequence = ++selectionSequence; @@ -61,4 +68,30 @@ export const useSelectMapImage = (): ((imageName: string) => void) => { }, [commands] ); + + const selectCluster = useCallback( + (primaryImageName: string, imageNames: string[]) => { + const sequence = ++selectionSequence; + + galleryImages + .resolveMany([primaryImageName]) + .then((images) => { + const image = images.at(0); + + if (!image || sequence !== selectionSequence) { + return; + } + + // Map points are always images, so every key is kind-tagged 'image'. + const itemKeys = imageNames.map((name) => toGalleryItemKey({ kind: 'image', name })); + commands.gallery.setItemMultiSelection(itemKeys, legacyGeneratedImageToGalleryItem(image)); + }) + .catch(() => { + // Selection is simply left unchanged on hydrate failure. + }); + }, + [commands] + ); + + return useMemo(() => ({ selectCluster, selectImage }), [selectCluster, selectImage]); }; From 76adc9ae19d742bb1201362661916db68d7519eb Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 16 Aug 2026 15:29:54 -0400 Subject: [PATCH 2/3] chore(webv2): regenerate performance baselines after replaying onto main The base PRs merged, so this branch was replayed onto main and its stale baseline-regeneration commits dropped. These numbers come from a fresh build. --- .../performance/architecture-baseline.json | 30 +-- .../webv2/performance/browser-baseline.json | 192 +++++++++--------- 2 files changed, 112 insertions(+), 110 deletions(-) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 54840b0fb88..2407937d5c0 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 830924, + "brotliBytes": 831092, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 829530, + "gzipBytes": 829649, "imageRawBytes": 156376, - "initialRawBytes": 1929616, + "initialRawBytes": 1929955, "largestAssetRawBytes": 746314, "otherAssetRawBytes": 0, - "ownedRawBytes": 66668, + "ownedRawBytes": 66666, "requestCount": 41, "scriptRequestCount": 25, "sourceOwners": [ @@ -279,15 +279,15 @@ ] }, "limits": { - "brotliBytes": 839234, + "brotliBytes": 839403, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 837826, + "gzipBytes": 837946, "imageRawBytes": 157940, - "initialRawBytes": 1948913, + "initialRawBytes": 1949255, "largestAssetRawBytes": 753778, "otherAssetRawBytes": 0, - "ownedRawBytes": 66668, + "ownedRawBytes": 66666, "requestCount": 41, "scriptRequestCount": 25 }, @@ -297,12 +297,12 @@ }, "editor": { "baseline": { - "brotliBytes": 1024234, + "brotliBytes": 1024541, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1019774, + "gzipBytes": 1020050, "imageRawBytes": 156376, - "initialRawBytes": 2511015, + "initialRawBytes": 2511355, "largestAssetRawBytes": 746314, "otherAssetRawBytes": 0, "ownedRawBytes": 107337, @@ -815,12 +815,12 @@ ] }, "limits": { - "brotliBytes": 1034477, + "brotliBytes": 1034787, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 1029972, + "gzipBytes": 1030251, "imageRawBytes": 157940, - "initialRawBytes": 2536126, + "initialRawBytes": 2536469, "largestAssetRawBytes": 753778, "otherAssetRawBytes": 0, "ownedRawBytes": 107337, @@ -832,7 +832,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-08-15", + "capturedAt": "2026-08-16", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 90f0ee96089..a14a68293de 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,6 +1,6 @@ { "browserExecutable": "/home/lstein/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome", - "capturedAt": "2026-08-15", + "capturedAt": "2026-08-16", "routes": [ { "activatedResourceBaseline": { @@ -25,11 +25,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 176.90000009536743, + "domContentLoadedMedianMs": 143.7999997138977, "id": "launchpad", "layoutSwitchMedianMs": 0, - "loadMedianMs": 176.90000009536743, - "longestTaskMaxMs": 75, + "loadMedianMs": 143.90000009536743, + "longestTaskMaxMs": 72, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -41,9 +41,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 36, - "scriptRawBytes": 1600788, + "scriptRawBytes": 1601127, "scriptRequestCount": 32, - "totalRawBytes": 1761706 + "totalRawBytes": 1762045 }, "resourceLimits": { "cssRawBytes": 2181, @@ -52,11 +52,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 36, - "scriptRawBytes": 1616796, + "scriptRawBytes": 1617139, "scriptRequestCount": 32, - "totalRawBytes": 1779324 + "totalRawBytes": 1779666 }, - "routeReadyMedianMs": 328.5, + "routeReadyMedianMs": 295.59999990463257, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -83,11 +83,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 137.7999997138977, + "domContentLoadedMedianMs": 155.2000002861023, "id": "launchpad", "layoutSwitchMedianMs": 0, - "loadMedianMs": 137.7999997138977, - "longestTaskMaxMs": 70, + "loadMedianMs": 155.2000002861023, + "longestTaskMaxMs": 72, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -99,9 +99,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 36, - "scriptRawBytes": 1600788, + "scriptRawBytes": 1601127, "scriptRequestCount": 32, - "totalRawBytes": 1761706 + "totalRawBytes": 1762045 }, "resourceLimits": { "cssRawBytes": 2181, @@ -110,11 +110,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 36, - "scriptRawBytes": 1616796, + "scriptRawBytes": 1617139, "scriptRequestCount": 32, - "totalRawBytes": 1779324 + "totalRawBytes": 1779666 }, - "routeReadyMedianMs": 307.19999980926514, + "routeReadyMedianMs": 322.8999996185303, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -141,13 +141,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 151.2000002861023, + "domContentLoadedMedianMs": 156.40000009536743, "id": "editor-minimal", "layoutSwitchMedianMs": 0, - "loadMedianMs": 151.30000019073486, + "loadMedianMs": 156.40000009536743, "longestTaskMaxMs": 103, "owner": "workbench", - "projectSwitchMedianMs": 41.200000286102295, + "projectSwitchMedianMs": 45.39999961853027, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -157,9 +157,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3342088, + "scriptRawBytes": 3343388, "scriptRequestCount": 107, - "totalRawBytes": 3674795 + "totalRawBytes": 3676095 }, "resourceLimits": { "cssRawBytes": 17748, @@ -168,11 +168,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3375509, + "scriptRawBytes": 3376822, "scriptRequestCount": 107, - "totalRawBytes": 3711543 + "totalRawBytes": 3712856 }, - "routeReadyMedianMs": 698.1000003814697, + "routeReadyMedianMs": 702.1000003814697, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -199,13 +199,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 143.59999990463257, + "domContentLoadedMedianMs": 150.30000019073486, "id": "editor-minimal", "layoutSwitchMedianMs": 0, - "loadMedianMs": 143.69999980926514, - "longestTaskMaxMs": 108, + "loadMedianMs": 150.30000019073486, + "longestTaskMaxMs": 104, "owner": "workbench", - "projectSwitchMedianMs": 61.40000009536743, + "projectSwitchMedianMs": 59, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -215,9 +215,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3342088, + "scriptRawBytes": 3343388, "scriptRequestCount": 107, - "totalRawBytes": 3674795 + "totalRawBytes": 3676095 }, "resourceLimits": { "cssRawBytes": 17748, @@ -226,11 +226,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3375509, + "scriptRawBytes": 3376822, "scriptRequestCount": 107, - "totalRawBytes": 3711543 + "totalRawBytes": 3712856 }, - "routeReadyMedianMs": 774.6999998092651, + "routeReadyMedianMs": 781.8000001907349, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -242,9 +242,9 @@ "largestAssetRawBytes": 135469, "otherRawBytes": 0, "requestCount": 5, - "scriptRawBytes": 283748, + "scriptRawBytes": 283733, "scriptRequestCount": 5, - "totalRawBytes": 283748 + "totalRawBytes": 283733 }, "activatedResourceLimits": { "cssRawBytes": 0, @@ -253,15 +253,15 @@ "largestAssetRawBytes": 136824, "otherRawBytes": 0, "requestCount": 5, - "scriptRawBytes": 286586, + "scriptRawBytes": 286571, "scriptRequestCount": 5, - "totalRawBytes": 286586 + "totalRawBytes": 286571 }, - "domContentLoadedMedianMs": 140.69999980926514, + "domContentLoadedMedianMs": 139.2999997138977, "id": "editor-canvas", - "layoutSwitchMedianMs": 70.30000019073486, - "loadMedianMs": 140.69999980926514, - "longestTaskMaxMs": 115, + "layoutSwitchMedianMs": 76.60000038146973, + "loadMedianMs": 139.2999997138977, + "longestTaskMaxMs": 104, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -273,9 +273,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 118, - "scriptRawBytes": 3625836, + "scriptRawBytes": 3627121, "scriptRequestCount": 112, - "totalRawBytes": 3958543 + "totalRawBytes": 3959828 }, "resourceLimits": { "cssRawBytes": 17748, @@ -284,11 +284,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 118, - "scriptRawBytes": 3662095, + "scriptRawBytes": 3663393, "scriptRequestCount": 112, - "totalRawBytes": 3998129 + "totalRawBytes": 3999427 }, - "routeReadyMedianMs": 1218.5, + "routeReadyMedianMs": 1213.3000001907349, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -300,9 +300,9 @@ "largestAssetRawBytes": 135469, "otherRawBytes": 0, "requestCount": 5, - "scriptRawBytes": 283748, + "scriptRawBytes": 283733, "scriptRequestCount": 5, - "totalRawBytes": 283748 + "totalRawBytes": 283733 }, "activatedResourceLimits": { "cssRawBytes": 0, @@ -311,15 +311,15 @@ "largestAssetRawBytes": 136824, "otherRawBytes": 0, "requestCount": 5, - "scriptRawBytes": 286586, + "scriptRawBytes": 286571, "scriptRequestCount": 5, - "totalRawBytes": 286586 + "totalRawBytes": 286571 }, - "domContentLoadedMedianMs": 142.2999997138977, + "domContentLoadedMedianMs": 135.5, "id": "editor-canvas", - "layoutSwitchMedianMs": 183.90000009536743, - "loadMedianMs": 142.2999997138977, - "longestTaskMaxMs": 365, + "layoutSwitchMedianMs": 184.80000019073486, + "loadMedianMs": 135.59999990463257, + "longestTaskMaxMs": 369, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -331,9 +331,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 118, - "scriptRawBytes": 3625836, + "scriptRawBytes": 3627121, "scriptRequestCount": 112, - "totalRawBytes": 3958543 + "totalRawBytes": 3959828 }, "resourceLimits": { "cssRawBytes": 17748, @@ -342,11 +342,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 118, - "scriptRawBytes": 3662095, + "scriptRawBytes": 3663393, "scriptRequestCount": 112, - "totalRawBytes": 3998129 + "totalRawBytes": 3999427 }, - "routeReadyMedianMs": 1359.9000000953674, + "routeReadyMedianMs": 1360.4000000953674, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -373,11 +373,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 165.19999980926514, + "domContentLoadedMedianMs": 151.40000009536743, "id": "editor-workflow", - "layoutSwitchMedianMs": 74.19999980926514, - "loadMedianMs": 165.19999980926514, - "longestTaskMaxMs": 102, + "layoutSwitchMedianMs": 72.7999997138977, + "loadMedianMs": 151.40000009536743, + "longestTaskMaxMs": 106, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -389,9 +389,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3342088, + "scriptRawBytes": 3343388, "scriptRequestCount": 107, - "totalRawBytes": 3674795 + "totalRawBytes": 3676095 }, "resourceLimits": { "cssRawBytes": 17748, @@ -400,11 +400,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3375509, + "scriptRawBytes": 3376822, "scriptRequestCount": 107, - "totalRawBytes": 3711543 + "totalRawBytes": 3712856 }, - "routeReadyMedianMs": 1233.6999998092651, + "routeReadyMedianMs": 1235, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -431,11 +431,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 143.30000019073486, + "domContentLoadedMedianMs": 142, "id": "editor-workflow", - "layoutSwitchMedianMs": 117.40000009536743, - "loadMedianMs": 143.30000019073486, - "longestTaskMaxMs": 380, + "layoutSwitchMedianMs": 115.10000038146973, + "loadMedianMs": 142, + "longestTaskMaxMs": 379, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -447,9 +447,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3342088, + "scriptRawBytes": 3343388, "scriptRequestCount": 107, - "totalRawBytes": 3674795 + "totalRawBytes": 3676095 }, "resourceLimits": { "cssRawBytes": 17748, @@ -458,11 +458,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3375509, + "scriptRawBytes": 3376822, "scriptRequestCount": 107, - "totalRawBytes": 3711543 + "totalRawBytes": 3712856 }, - "routeReadyMedianMs": 1307, + "routeReadyMedianMs": 1302.9000000953674, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -489,11 +489,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 138.90000009536743, + "domContentLoadedMedianMs": 141.59999990463257, "id": "editor-gallery", - "layoutSwitchMedianMs": 36, - "loadMedianMs": 138.90000009536743, - "longestTaskMaxMs": 103, + "layoutSwitchMedianMs": 39.39999961853027, + "loadMedianMs": 141.59999990463257, + "longestTaskMaxMs": 105, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -505,9 +505,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3342088, + "scriptRawBytes": 3343388, "scriptRequestCount": 107, - "totalRawBytes": 3674795 + "totalRawBytes": 3676095 }, "resourceLimits": { "cssRawBytes": 17748, @@ -516,11 +516,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3375509, + "scriptRawBytes": 3376822, "scriptRequestCount": 107, - "totalRawBytes": 3711543 + "totalRawBytes": 3712856 }, - "routeReadyMedianMs": 1469.1000003814697, + "routeReadyMedianMs": 1474.0999999046326, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -547,10 +547,10 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 161.40000009536743, + "domContentLoadedMedianMs": 143.30000019073486, "id": "editor-gallery", - "layoutSwitchMedianMs": 88.69999980926514, - "loadMedianMs": 161.40000009536743, + "layoutSwitchMedianMs": 94.40000009536743, + "loadMedianMs": 143.30000019073486, "longestTaskMaxMs": 136, "owner": "gallery", "projectSwitchMedianMs": 0, @@ -563,9 +563,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3342088, + "scriptRawBytes": 3343388, "scriptRequestCount": 107, - "totalRawBytes": 3674795 + "totalRawBytes": 3676095 }, "resourceLimits": { "cssRawBytes": 17748, @@ -574,11 +574,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3375509, + "scriptRawBytes": 3376822, "scriptRequestCount": 107, - "totalRawBytes": 3711543 + "totalRawBytes": 3712856 }, - "routeReadyMedianMs": 1598.4000000953674, + "routeReadyMedianMs": 1567.5, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -1775,6 +1775,7 @@ "source:src/workbench/widgets/gallery/manifest.ts", "source:src/workbench/widgets/generate/manifest.ts", "source:src/workbench/widgets/image-map/ImageMapDataRuntime.tsx", + "source:src/workbench/widgets/image-map/ImageMapHeaderActions.tsx", "source:src/workbench/widgets/image-map/ImageMapWidgetFooter.tsx", "source:src/workbench/widgets/image-map/ImageMapWidgetView.tsx", "source:src/workbench/widgets/image-map/implementation.ts", @@ -2772,6 +2773,7 @@ "source:src/workbench/widgets/gallery/manifest.ts", "source:src/workbench/widgets/generate/manifest.ts", "source:src/workbench/widgets/image-map/ImageMapDataRuntime.tsx", + "source:src/workbench/widgets/image-map/ImageMapHeaderActions.tsx", "source:src/workbench/widgets/image-map/ImageMapWidgetFooter.tsx", "source:src/workbench/widgets/image-map/ImageMapWidgetView.tsx", "source:src/workbench/widgets/image-map/implementation.ts", From 408859c9ec5bc7650cacac47690cbdc02bb5651e Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 16 Aug 2026 16:03:29 -0400 Subject: [PATCH 3/3] fix(webv2): select the cluster's board, and use the shared toggle control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from an adversarial review of cluster selection: - `selectCluster` now calls `commands.gallery.selectBoard` before dispatching, as `selectImage` already did. The map spans every accessible board while the selection commands stamp `selectedImageQuery` from whatever board the gallery is currently showing, so a cluster picked from another board left that query describing a list none of its images were in — the grid never showed the selection and Preview's next/prev went dead. That is the regression the sibling call's comment exists to prevent; the cluster path had simply not inherited it. - The header control is `ToggleIconButton` from @platform/ui rather than a hand-rolled IconButton. The shared component is this exact control, and its filled-when-on convention is what makes "on" legible; the local copy rendered subtle-gray where every other toggle in the app renders solid accent. - `selectCluster` has tests: board-before-selection ordering, the name to image-kinded-key mapping with its clicked-point anchor, the unresolvable primary, and the sequence guard shared with `selectImage` — the last being the behaviour the hook's doc comment claims but nothing checked. The duplicated `Boolean(values.clickSelectsCluster)` reads move behind one accessor, which costs the editor bundle one small module; the baseline records it. Selection semantics (unhydrated cluster keys, and the size of a persisted selection) are deliberately left alone. --- .../webv2/performance/browser-baseline.json | 162 +++++++++--------- .../workbench/image-map/imageMapSettings.ts | 7 + .../image-map/ImageMapHeaderActions.tsx | 29 ++-- .../widgets/image-map/ImageMapWidgetView.tsx | 3 +- .../useSelectMapImage.browser.test.tsx | 84 ++++++++- .../widgets/image-map/useSelectMapImage.ts | 5 + 6 files changed, 188 insertions(+), 102 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index a14a68293de..ef827ab3f9e 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -25,11 +25,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 143.7999997138977, + "domContentLoadedMedianMs": 135.90000009536743, "id": "launchpad", "layoutSwitchMedianMs": 0, - "loadMedianMs": 143.90000009536743, - "longestTaskMaxMs": 72, + "loadMedianMs": 136, + "longestTaskMaxMs": 77, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -56,7 +56,7 @@ "scriptRequestCount": 32, "totalRawBytes": 1779666 }, - "routeReadyMedianMs": 295.59999990463257, + "routeReadyMedianMs": 297.8999996185303, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -83,11 +83,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 155.2000002861023, + "domContentLoadedMedianMs": 131.09999990463257, "id": "launchpad", "layoutSwitchMedianMs": 0, - "loadMedianMs": 155.2000002861023, - "longestTaskMaxMs": 72, + "loadMedianMs": 131.19999980926514, + "longestTaskMaxMs": 77, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -114,7 +114,7 @@ "scriptRequestCount": 32, "totalRawBytes": 1779666 }, - "routeReadyMedianMs": 322.8999996185303, + "routeReadyMedianMs": 303.90000009536743, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -141,13 +141,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 156.40000009536743, + "domContentLoadedMedianMs": 133.90000009536743, "id": "editor-minimal", "layoutSwitchMedianMs": 0, - "loadMedianMs": 156.40000009536743, - "longestTaskMaxMs": 103, + "loadMedianMs": 133.90000009536743, + "longestTaskMaxMs": 121, "owner": "workbench", - "projectSwitchMedianMs": 45.39999961853027, + "projectSwitchMedianMs": 34.59999990463257, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -157,9 +157,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3343388, + "scriptRawBytes": 3343019, "scriptRequestCount": 107, - "totalRawBytes": 3676095 + "totalRawBytes": 3675726 }, "resourceLimits": { "cssRawBytes": 17748, @@ -168,11 +168,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3376822, + "scriptRawBytes": 3376450, "scriptRequestCount": 107, - "totalRawBytes": 3712856 + "totalRawBytes": 3712484 }, - "routeReadyMedianMs": 702.1000003814697, + "routeReadyMedianMs": 704.2000002861023, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -199,13 +199,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 150.30000019073486, + "domContentLoadedMedianMs": 136.09999990463257, "id": "editor-minimal", "layoutSwitchMedianMs": 0, - "loadMedianMs": 150.30000019073486, - "longestTaskMaxMs": 104, + "loadMedianMs": 136.2000002861023, + "longestTaskMaxMs": 125, "owner": "workbench", - "projectSwitchMedianMs": 59, + "projectSwitchMedianMs": 57.89999961853027, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -215,9 +215,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3343388, + "scriptRawBytes": 3343019, "scriptRequestCount": 107, - "totalRawBytes": 3676095 + "totalRawBytes": 3675726 }, "resourceLimits": { "cssRawBytes": 17748, @@ -226,11 +226,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3376822, + "scriptRawBytes": 3376450, "scriptRequestCount": 107, - "totalRawBytes": 3712856 + "totalRawBytes": 3712484 }, - "routeReadyMedianMs": 781.8000001907349, + "routeReadyMedianMs": 835.0999999046326, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -257,11 +257,11 @@ "scriptRequestCount": 5, "totalRawBytes": 286571 }, - "domContentLoadedMedianMs": 139.2999997138977, + "domContentLoadedMedianMs": 133.09999990463257, "id": "editor-canvas", - "layoutSwitchMedianMs": 76.60000038146973, - "loadMedianMs": 139.2999997138977, - "longestTaskMaxMs": 104, + "layoutSwitchMedianMs": 81, + "loadMedianMs": 133.09999990463257, + "longestTaskMaxMs": 125, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -273,9 +273,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 118, - "scriptRawBytes": 3627121, + "scriptRawBytes": 3626752, "scriptRequestCount": 112, - "totalRawBytes": 3959828 + "totalRawBytes": 3959459 }, "resourceLimits": { "cssRawBytes": 17748, @@ -284,11 +284,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 118, - "scriptRawBytes": 3663393, + "scriptRawBytes": 3663020, "scriptRequestCount": 112, - "totalRawBytes": 3999427 + "totalRawBytes": 3999054 }, - "routeReadyMedianMs": 1213.3000001907349, + "routeReadyMedianMs": 1193.9000000953674, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -315,11 +315,11 @@ "scriptRequestCount": 5, "totalRawBytes": 286571 }, - "domContentLoadedMedianMs": 135.5, + "domContentLoadedMedianMs": 128.39999961853027, "id": "editor-canvas", - "layoutSwitchMedianMs": 184.80000019073486, - "loadMedianMs": 135.59999990463257, - "longestTaskMaxMs": 369, + "layoutSwitchMedianMs": 193, + "loadMedianMs": 128.39999961853027, + "longestTaskMaxMs": 472, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -331,9 +331,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 118, - "scriptRawBytes": 3627121, + "scriptRawBytes": 3626752, "scriptRequestCount": 112, - "totalRawBytes": 3959828 + "totalRawBytes": 3959459 }, "resourceLimits": { "cssRawBytes": 17748, @@ -342,11 +342,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 118, - "scriptRawBytes": 3663393, + "scriptRawBytes": 3663020, "scriptRequestCount": 112, - "totalRawBytes": 3999427 + "totalRawBytes": 3999054 }, - "routeReadyMedianMs": 1360.4000000953674, + "routeReadyMedianMs": 1382.7999997138977, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -373,11 +373,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 151.40000009536743, + "domContentLoadedMedianMs": 122.2999997138977, "id": "editor-workflow", - "layoutSwitchMedianMs": 72.7999997138977, - "loadMedianMs": 151.40000009536743, - "longestTaskMaxMs": 106, + "layoutSwitchMedianMs": 78.59999990463257, + "loadMedianMs": 122.2999997138977, + "longestTaskMaxMs": 141, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -389,9 +389,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3343388, + "scriptRawBytes": 3343019, "scriptRequestCount": 107, - "totalRawBytes": 3676095 + "totalRawBytes": 3675726 }, "resourceLimits": { "cssRawBytes": 17748, @@ -400,11 +400,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3376822, + "scriptRawBytes": 3376450, "scriptRequestCount": 107, - "totalRawBytes": 3712856 + "totalRawBytes": 3712484 }, - "routeReadyMedianMs": 1235, + "routeReadyMedianMs": 1155.4000000953674, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -431,11 +431,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 142, + "domContentLoadedMedianMs": 135.60000038146973, "id": "editor-workflow", - "layoutSwitchMedianMs": 115.10000038146973, - "loadMedianMs": 142, - "longestTaskMaxMs": 379, + "layoutSwitchMedianMs": 120.09999990463257, + "loadMedianMs": 135.60000038146973, + "longestTaskMaxMs": 443, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -447,9 +447,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3343388, + "scriptRawBytes": 3343019, "scriptRequestCount": 107, - "totalRawBytes": 3676095 + "totalRawBytes": 3675726 }, "resourceLimits": { "cssRawBytes": 17748, @@ -458,11 +458,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3376822, + "scriptRawBytes": 3376450, "scriptRequestCount": 107, - "totalRawBytes": 3712856 + "totalRawBytes": 3712484 }, - "routeReadyMedianMs": 1302.9000000953674, + "routeReadyMedianMs": 1271.8000001907349, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -489,11 +489,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 141.59999990463257, + "domContentLoadedMedianMs": 121.5, "id": "editor-gallery", - "layoutSwitchMedianMs": 39.39999961853027, - "loadMedianMs": 141.59999990463257, - "longestTaskMaxMs": 105, + "layoutSwitchMedianMs": 32.90000009536743, + "loadMedianMs": 121.5, + "longestTaskMaxMs": 104, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -505,9 +505,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3343388, + "scriptRawBytes": 3343019, "scriptRequestCount": 107, - "totalRawBytes": 3676095 + "totalRawBytes": 3675726 }, "resourceLimits": { "cssRawBytes": 17748, @@ -516,11 +516,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3376822, + "scriptRawBytes": 3376450, "scriptRequestCount": 107, - "totalRawBytes": 3712856 + "totalRawBytes": 3712484 }, - "routeReadyMedianMs": 1474.0999999046326, + "routeReadyMedianMs": 1394.3000001907349, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -547,11 +547,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 143.30000019073486, + "domContentLoadedMedianMs": 123.5, "id": "editor-gallery", - "layoutSwitchMedianMs": 94.40000009536743, - "loadMedianMs": 143.30000019073486, - "longestTaskMaxMs": 136, + "layoutSwitchMedianMs": 98.69999980926514, + "loadMedianMs": 123.5, + "longestTaskMaxMs": 142, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -563,9 +563,9 @@ "largestAssetRawBytes": 746314, "otherRawBytes": 135095, "requestCount": 113, - "scriptRawBytes": 3343388, + "scriptRawBytes": 3343019, "scriptRequestCount": 107, - "totalRawBytes": 3676095 + "totalRawBytes": 3675726 }, "resourceLimits": { "cssRawBytes": 17748, @@ -574,11 +574,11 @@ "largestAssetRawBytes": 753778, "otherRawBytes": 136446, "requestCount": 113, - "scriptRawBytes": 3376822, + "scriptRawBytes": 3376450, "scriptRequestCount": 107, - "totalRawBytes": 3712856 + "totalRawBytes": 3712484 }, - "routeReadyMedianMs": 1567.5, + "routeReadyMedianMs": 1528.0999999046326, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -1635,6 +1635,7 @@ "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useImageActions.ts", "source:src/workbench/image-map/api.ts", + "source:src/workbench/image-map/imageMapSettings.ts", "source:src/workbench/image-map/imageMapStore.ts", "source:src/workbench/invocation.ts", "source:src/workbench/invocationSubmit.ts", @@ -2577,6 +2578,7 @@ "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useImageActions.ts", "source:src/workbench/image-map/api.ts", + "source:src/workbench/image-map/imageMapSettings.ts", "source:src/workbench/image-map/imageMapStore.ts", "source:src/workbench/invocation.ts", "source:src/workbench/invocationSubmit.ts", diff --git a/invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts b/invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts new file mode 100644 index 00000000000..48f26a956d5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts @@ -0,0 +1,7 @@ +/** + * Accessors for the Image Map widget's persisted view settings, so the header + * toggle and the view read the same shape from one place. + */ + +export const getImageMapClickSelectsCluster = (values: Record): boolean => + values.clickSelectsCluster === true; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx index 35654c45ddd..46ed07959b3 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx @@ -1,7 +1,7 @@ import type { WidgetViewProps } from '@workbench/widgetContracts'; -import { Icon } from '@chakra-ui/react'; -import { IconButton, Tooltip } from '@platform/ui'; +import { ToggleIconButton } from '@platform/ui'; +import { getImageMapClickSelectsCluster } from '@workbench/image-map/imageMapSettings'; import { useWidgetValuesSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; import { GroupIcon } from 'lucide-react'; import { useCallback } from 'react'; @@ -13,24 +13,19 @@ import { useCallback } from 'react'; */ export const ImageMapHeaderActions = (_props: WidgetViewProps) => { const { widgets } = useWorkbenchCommands(); - const clickSelectsCluster = useWidgetValuesSelector('image-map', (values) => Boolean(values.clickSelectsCluster)); + const clickSelectsCluster = useWidgetValuesSelector('image-map', getImageMapClickSelectsCluster); const handleToggle = useCallback( - () => widgets.patchValues('image-map', { clickSelectsCluster: !clickSelectsCluster }), - [clickSelectsCluster, widgets] + (checked: boolean) => widgets.patchValues('image-map', { clickSelectsCluster: checked }), + [widgets] ); return ( - - - - - + ); }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx index 516610ceb43..14089f6f483 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx @@ -1,6 +1,7 @@ import type { WidgetViewProps } from '@workbench/widgetContracts'; import { Button, Center, Spinner, Stack, Text } from '@chakra-ui/react'; +import { getImageMapClickSelectsCluster } from '@workbench/image-map/imageMapSettings'; import { ensureImageMapLoaded, imageMapStore, refreshImageMapPoints } from '@workbench/image-map/imageMapStore'; import { useWidgetValuesSelector } from '@workbench/WorkbenchContext'; import { lazy, Suspense, useEffect } from 'react'; @@ -29,7 +30,7 @@ const plotLoadingFallback = ( */ export const ImageMapWidgetView = (_props: WidgetViewProps) => { const { data, error, loadState, renderError } = imageMapStore.useSnapshot(); - const clickSelectsCluster = useWidgetValuesSelector('image-map', (values) => Boolean(values.clickSelectsCluster)); + const clickSelectsCluster = useWidgetValuesSelector('image-map', getImageMapClickSelectsCluster); useEffect(() => { ensureImageMapLoaded(); 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 38b2b39a216..26029edda78 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 @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ resolveMany: vi.fn(), selectBoard: vi.fn(), + setItemMultiSelection: vi.fn(), selectItem: vi.fn(), })); @@ -16,7 +17,11 @@ vi.mock('@features/gallery', () => ({ vi.mock('@workbench/WorkbenchContext', () => ({ useWorkbenchCommands: () => ({ - gallery: { selectBoard: mocks.selectBoard, selectItem: mocks.selectItem }, + gallery: { + selectBoard: mocks.selectBoard, + selectItem: mocks.selectItem, + setItemMultiSelection: mocks.setItemMultiSelection, + }, }), })); @@ -28,14 +33,18 @@ let host: HTMLDivElement | null = null; let root: Root | null = null; /** Published from an effect, not during render, so the probe stays render-pure. */ -const handle: { click: ((imageName: string) => void) | null } = { click: null }; +const handle: { + click: ((imageName: string) => void) | null; + clickCluster: ((primaryImageName: string, imageNames: string[]) => void) | null; +} = { click: null, clickCluster: null }; const Probe = () => { - const { selectImage } = useMapSelection(); + const { selectCluster, selectImage } = useMapSelection(); useEffect(() => { handle.click = selectImage; - }, [selectImage]); + handle.clickCluster = selectCluster; + }, [selectCluster, selectImage]); return null; }; @@ -61,6 +70,7 @@ const unmount = async () => { root = null; host = null; handle.click = null; + handle.clickCluster = null; }; /** A promise plus the trigger that settles it, so click ordering can be forced. */ @@ -80,6 +90,7 @@ afterEach(async () => { mocks.resolveMany.mockReset(); mocks.selectBoard.mockReset(); mocks.selectItem.mockReset(); + mocks.setItemMultiSelection.mockReset(); }); describe('useMapSelection', () => { @@ -118,6 +129,71 @@ describe('useMapSelection', () => { expect(mocks.selectItem).not.toHaveBeenCalled(); }); + it('selects the primary image\u2019s board before the cluster', async () => { + // Same reason as the single-image case: setItemMultiSelection stamps the + // navigation query from the list the gallery is currently showing, so a + // cluster picked from another board would leave that query describing a + // list none of these images are in. + mocks.resolveMany.mockResolvedValue([{ boardId: 'board-landscapes', image_name: 'a.png' }]); + await mount(); + + await flush(() => handle.clickCluster?.('a.png', ['a.png', 'b.png', 'c.png'])); + + expect(mocks.selectBoard).toHaveBeenCalledWith('board-landscapes'); + expect(mocks.selectBoard.mock.invocationCallOrder[0]).toBeLessThan( + mocks.setItemMultiSelection.mock.invocationCallOrder[0] + ); + }); + + it('passes every cluster member as an image-kinded key, anchored on the clicked one', async () => { + mocks.resolveMany.mockResolvedValue([{ boardId: 'board-a', image_name: 'a.png' }]); + await mount(); + + await flush(() => handle.clickCluster?.('a.png', ['a.png', 'b.png'])); + + expect(mocks.setItemMultiSelection).toHaveBeenCalledTimes(1); + const [keys, primary] = mocks.setItemMultiSelection.mock.calls[0] ?? []; + expect(keys).toEqual([ + { kind: 'image', name: 'a.png' }, + { kind: 'image', name: 'b.png' }, + ]); + expect(primary).toEqual({ boardId: 'board-a', image_name: 'a.png' }); + }); + + it('leaves the selection alone when the primary image cannot be resolved', async () => { + mocks.resolveMany.mockResolvedValue([]); + await mount(); + + await flush(() => handle.clickCluster?.('gone.png', ['gone.png', 'b.png'])); + + expect(mocks.selectBoard).not.toHaveBeenCalled(); + expect(mocks.setItemMultiSelection).not.toHaveBeenCalled(); + }); + + it('shares one sequence guard across both modes, so the newer click wins', async () => { + // The two entry points must not race each other: switching cluster mode + // mid-flight would otherwise let a stale resolution overwrite a newer + // selection. + const slow = deferred<{ image_name: string }[]>(); + const fast = deferred<{ image_name: string }[]>(); + + mocks.resolveMany.mockReturnValueOnce(slow.promise).mockReturnValueOnce(fast.promise); + await mount(); + + await flush(() => { + handle.clickCluster?.('slow.png', ['slow.png']); + handle.click?.('fast.png'); + }); + await flush(() => { + fast.resolve([{ image_name: 'fast.png' }]); + slow.resolve([{ image_name: 'slow.png' }]); + }); + + expect(mocks.setItemMultiSelection).not.toHaveBeenCalled(); + expect(mocks.selectItem).toHaveBeenCalledTimes(1); + expect(mocks.selectItem.mock.calls[0]?.[0]).toEqual({ image_name: 'fast.png' }); + }); + it('ignores a slow click that resolves after a newer one', async () => { const slow = deferred<{ image_name: string }[]>(); const fast = deferred<{ image_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 7d4d5a5c236..1d3f65ecc7b 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts @@ -82,6 +82,11 @@ export const useMapSelection = (): MapSelectionActions => { return; } + // Same reason as `selectImage` above: the multi-selection command + // stamps `selectedImageQuery` from the board the gallery is showing, + // so without landing on the primary image's board first the query + // describes a list none of these images are in. + commands.gallery.selectBoard(image.boardId); // Map points are always images, so every key is kind-tagged 'image'. const itemKeys = imageNames.map((name) => toGalleryItemKey({ kind: 'image', name })); commands.gallery.setItemMultiSelection(itemKeys, legacyGeneratedImageToGalleryItem(image));