diff --git a/invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts b/invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts index d272d0c5181..3921580050e 100644 --- a/invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts +++ b/invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts @@ -64,7 +64,7 @@ describe('floatWidget', () => { expect(floating?.queue.x).toBeGreaterThan(floating?.gallery.x ?? 0); }); - it('refuses to float the last center view, which would blank the work surface', () => { + it('floats the last center view, leaving the surface to its fallback view', () => { let state = createInitialWorkbenchState(); const centerInstanceIds = getActiveProject(state).widgetRegions.center.instanceIds; @@ -74,7 +74,52 @@ describe('floatWidget', () => { const lastCenterInstanceId = centerInstanceIds[0]; expect(getActiveProject(state).widgetRegions.center.instanceIds).toEqual([lastCenterInstanceId]); - expect(workbenchReducer(state, { instanceId: lastCenterInstanceId, type: 'floatWidget' })).toBe(state); + + state = workbenchReducer(state, { instanceId: lastCenterInstanceId, type: 'floatWidget' }); + const center = getActiveProject(state).widgetRegions.center; + + // The surface may go empty — the fallback view carries it — and the + // window remembers the center as its dock-back target. The active pointer + // holds the floated instance so docking restores it as the active view, + // and the center takes no collapsed state. + expect(center.instanceIds).toEqual([]); + expect(center.activeInstanceId).toBe(lastCenterInstanceId); + expect(center.isCollapsed).toBe(false); + expect(getActiveProject(state).floatingWidgets?.[lastCenterInstanceId]).toMatchObject({ + returnRegion: 'center', + }); + }); + + it('docks back into the region the float was asked from, not the first member region', () => { + // The Edit preset places the preview in the center AND the right rail, and + // `right` precedes `center` in the region map — without the origin hint, + // floating it from the center chrome would detach the rail's membership + // and dock it back there. + let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'edit', type: 'applyPreset' }); + state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' }); + const project = getActiveProject(state); + + expect(project.widgetRegions.center.instanceIds).not.toContain('preview'); + expect(project.widgetRegions.right.instanceIds).toContain('preview'); + expect(project.floatingWidgets?.preview).toMatchObject({ returnRegion: 'center' }); + + const docked = workbenchReducer(state, { instanceId: 'preview', type: 'dockFloatingWidget' }); + + expect(getActiveProject(docked).widgetRegions.center.instanceIds).toContain('preview'); + expect(getActiveProject(docked).widgetRegions.center.activeInstanceId).toBe('preview'); + }); + + it('falls back to a member region when the float carries no region hint', () => { + let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'edit', type: 'applyPreset' }); + state = workbenchReducer(state, { instanceId: 'preview', type: 'floatWidget' }); + const project = getActiveProject(state); + const floating = project.floatingWidgets?.preview; + + // The hint is what makes dock-back deterministic; without it some member + // region still hosts the float — which one is region map order, not a + // contract. + expect(floating).toBeDefined(); + expect(['center', 'right']).toContain(floating!.returnRegion); }); it('collapses a rail it empties instead of leaving it open and blank', () => { @@ -239,6 +284,19 @@ describe('interaction with region placement', () => { expect(project.floatingWidgets?.gallery).toBeUndefined(); expect(project.widgetRegions.right.instanceIds).toContain('gallery'); }); + + it('selectRegionWidget docks a floating instance instead of double-rendering it', () => { + // The preview is still a member of the right rail while it floats in a + // window docked back to the center; selecting its rail slot would + // otherwise make the rail show it alongside the window. + let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'edit', type: 'applyPreset' }); + state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' }); + state = workbenchReducer(state, { region: 'right', type: 'selectRegionWidget', widgetId: 'preview' }); + const project = getActiveProject(state); + + expect(project.floatingWidgets?.preview).toBeUndefined(); + expect(project.widgetRegions.right.activeInstanceId).toBe('preview'); + }); }); describe('pure helpers', () => { @@ -337,30 +395,32 @@ describe('normalization of persisted floating windows', () => { }); }); - it('docks rather than empties the center region', () => { - const project = getActiveProject(createInitialWorkbenchState()); - const [onlyCenterInstanceId] = project.widgetRegions.center.instanceIds; - const normalized = normalizeWorkbenchProject({ - ...project, - floatingWidgets: { - [onlyCenterInstanceId]: { - heightPx: 300, - mode: 'windowed', - returnRegion: 'center', - stackOrder: 1, - widthPx: 400, - x: 10, - y: 10, - }, - }, - widgetRegions: { - ...project.widgetRegions, - center: { ...project.widgetRegions.center, instanceIds: [onlyCenterInstanceId] }, - }, - }); + it('lets a sole center view keep floating across a reload', () => { + // Floating the last center view empties the work surface into its fallback + // view, and the window's dock control is one click from restoring it. The + // persisted shape is an EMPTY center whose active pointer names the floated + // instance — normalization must honour it, not read it as missing center + // data and refill the default arrangement (which would inject views the + // project never placed). + let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'video', type: 'applyPreset' }); + state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' }); + const project = getActiveProject(state); + expect(project.widgetRegions.center.instanceIds).toEqual([]); - expect(normalized.floatingWidgets).toBeUndefined(); - expect(normalized.widgetRegions.center.instanceIds).toEqual([onlyCenterInstanceId]); + // The reducer output is exactly what autosave persists. + const normalized = normalizeWorkbenchProject(JSON.parse(JSON.stringify(project))); + + expect(normalized.floatingWidgets?.preview).toMatchObject({ returnRegion: 'center' }); + expect(normalized.widgetRegions.center.instanceIds).toEqual([]); + expect(normalized.widgetRegions.center.activeInstanceId).toBe('preview'); + + // Docking after the reload restores the view. + const docked = workbenchReducer( + { ...state, projects: state.projects.map((p) => (p.id === normalized.id ? normalized : p)) }, + { instanceId: 'preview', type: 'dockFloatingWidget' } + ); + + expect(getActiveProject(docked).widgetRegions.center.instanceIds).toEqual(['preview']); }); }); @@ -393,6 +453,22 @@ describe('interaction with presets and undo', () => { expect(getRegionsHolding(project, 'gallery')).toEqual([]); }); + it('a preset saved with the sole center view floating restores the emptied surface, not a phantom view', () => { + // Normalizing before the snapshot must not refill the emptied center with + // the default arrangement — the saved preset would otherwise carry center + // views the project never placed. + let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'video', type: 'applyPreset' }); + state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' }); + state = workbenchReducer(state, { presetId: 'video', type: 'saveLayoutPreset' }); + // Dock it, then revert to the preset that was saved with it floating. + state = workbenchReducer(state, { instanceId: 'preview', type: 'dockFloatingWidget' }); + state = workbenchReducer(state, { presetId: 'video', type: 'applyPreset' }); + const project = getActiveProject(state); + + expect(project.floatingWidgets?.preview).toBeDefined(); + expect(project.widgetRegions.center.instanceIds).toEqual([]); + }); + it('carries a saved preset’s floating window through account rehydration', () => { // Account presets are rebuilt by `cloneLayoutPresetSnapshot` on every load; // a field it forgets is a field the preset loses on the next reload. diff --git a/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts b/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts index d0af3f0ca89..328960405be 100644 --- a/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts +++ b/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts @@ -122,8 +122,9 @@ describe('built-in layout preset descriptors', () => { center: ['canvas', 'preview'], left: ['generate', 'upscale'], panels: { isBottomOpen: false, isLeftOpen: true, isRightOpen: true }, - // The Edit rail is the Layers panel alone; its editors are panes inside it. - right: ['layers'], + // The editors are panes inside the Layers panel; the preview docks + // behind Layers so its float/dock surface stays reachable by default. + right: ['layers', 'preview'], }, video: { active: { bottom: 'gallery:bottom', center: 'preview', left: 'video', right: 'gallery' }, diff --git a/invokeai/frontend/webv2/src/workbench/layoutPresets.ts b/invokeai/frontend/webv2/src/workbench/layoutPresets.ts index b5826cb7d71..71700d4e587 100644 --- a/invokeai/frontend/webv2/src/workbench/layoutPresets.ts +++ b/invokeai/frontend/webv2/src/workbench/layoutPresets.ts @@ -223,7 +223,10 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [ }), right: createRegion({ activeInstanceId: 'layers', - instanceIds: ['layers'], + // Preview docks behind Layers: the editors are Layers panes, but the + // preview is a floatable widget, and its float/dock pair and floated + // Invoke control are only reachable where it is actually placed. + instanceIds: ['layers', 'preview'], sizePx: 450, }), }, diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFloatButton.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFloatButton.browser.test.tsx index 5cae5619f2b..1275837c03e 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFloatButton.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFloatButton.browser.test.tsx @@ -12,9 +12,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * The float control replaced a menu item, so the conditions that used to hide - * that item now decide whether a button renders at all: a widget that may not - * float, and the last center view — floating it out would leave the work - * surface empty. Docking is covered by the floating window's own chrome. + * that item now decide whether a button renders at all: only a widget whose + * manifest forbids floating, and the floating region itself. Even the last + * center view may float — the emptied surface falls back to the center's + * fallback view until the window docks back. Docking is covered by the + * floating window's own chrome. */ const floatMocks = vi.hoisted(() => ({ @@ -119,7 +121,7 @@ describe('WidgetFloatButton', () => { }); expect(floatMocks.flushWorkbenchDrafts).toHaveBeenCalled(); - expect(floatMocks.float).toHaveBeenCalledWith('image-map-instance'); + expect(floatMocks.float).toHaveBeenCalledWith('image-map-instance', 'right'); expect(floatMocks.flushWorkbenchDrafts.mock.invocationCallOrder[0]).toBeLessThan( floatMocks.float.mock.invocationCallOrder[0] ); @@ -129,8 +131,8 @@ describe('WidgetFloatButton', () => { expect(await renderButton('right', false)).toBeNull(); }); - it('renders nothing for the last enabled center view', async () => { - expect(await renderButton('center')).toBeNull(); + it('renders for the last enabled center view — the emptied surface falls back until it docks back', async () => { + expect(await renderButton('center')).not.toBeNull(); }); it('renders for a center view that is not the last one', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx index 292e9b4c994..7ae1d9ed4e0 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx @@ -16,10 +16,9 @@ import { useMountEffect } from '@platform/react/useMountEffect'; import { IconButton } from '@platform/ui/Button'; import { Tooltip } from '@platform/ui/Tooltip'; import { useFocusRegionProps } from '@workbench/focusRegions'; +import { isWidgetRegion } from '@workbench/layoutContracts'; import { openWorkbenchSettings } from '@workbench/settings/settingsDialogStore'; import { resolveWidgetInstanceLabel } from '@workbench/widgetLabels'; -import { getEnabledCenterViewCount } from '@workbench/widgetPlacementCommands'; -import { areWidgetPlacementProjectsEqual, getWidgetPlacementProject } from '@workbench/widgetPlacementMeta'; import { useActiveProjectSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; import { clampPanelSize, @@ -27,7 +26,6 @@ import { getVisiblePanelCollapseThreshold, shouldSnapPanelShutAt, } from '@workbench/workbenchState'; -import { useWorkbenchWidgetRegistry } from '@workbench/WorkbenchWidgetRegistryContext'; import { PictureInPicture2Icon, SettingsIcon } from 'lucide-react'; import { useCallback, @@ -310,23 +308,29 @@ export const WidgetFloatButton = ({ region: WorkbenchRegion; }) => { const { t } = useTranslation(); - const placementProject = useActiveProjectSelector(getWidgetPlacementProject, areWidgetPlacementProjectsEqual); - const { getWidgetById } = useWorkbenchWidgetRegistry(); const { widgets } = useWorkbenchCommands(); + // A dialog or popover's chrome never floats; anything else is a dockable + // layout region, which is also the dock-back target. + const dockableRegion = isWidgetRegion(region) ? region : undefined; // Floating unmounts the docked subtree; the draft registry's cleanup only - // deregisters the flusher, so an uncommitted edit is lost without this. + // deregisters the flusher, so an uncommitted edit is lost without this. The + // region rides along: one instance may be placed in several regions (the + // preview lives in the center and a rail), and the window docks back into + // the one whose button was clicked. const handleFloat = useCallback(() => { + if (!dockableRegion) { + return; + } + flushWorkbenchDrafts(); - widgets.float(instanceId); - }, [instanceId, widgets]); + widgets.float(instanceId, dockableRegion); + }, [dockableRegion, instanceId, widgets]); // Floating is offered only from dockable regions; the floating window's own - // chrome carries the dock control. The last center *view* is not offered it - // either — floating it out would leave the work surface with nothing to - // show, which is why `closeWidgetPlacement` refuses the same removal. - const canFloat = - Boolean(manifest.allowFloating) && - region !== 'floating' && - !(region === 'center' && getEnabledCenterViewCount(placementProject, getWidgetById) === 1); + // chrome carries the dock control. Even the last center *view* may float: + // the emptied surface falls back to the center's fallback view, and the + // window's dock control restores it — only the destructive placements + // (`closeWidgetPlacement`, `toggleRegionWidget`) still refuse that. + const canFloat = Boolean(manifest.allowFloating) && dockableRegion !== undefined; if (!canFloat) { return null; diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts index 48c13802505..87b6f302400 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts @@ -54,7 +54,8 @@ const createDispatch = () => { const widgets: WorkbenchWidgetCommands = { dockFloating: (instanceId) => dispatch({ instanceId, type: 'dockFloatingWidget' }), - float: (instanceId) => dispatch({ instanceId, type: 'floatWidget' }), + float: (instanceId, region) => + dispatch(region ? { instanceId, region, type: 'floatWidget' } : { instanceId, type: 'floatWidget' }), focusFloating: (instanceId) => dispatch({ instanceId, type: 'focusFloatingWidget' }), move: (options) => dispatch({ ...options, type: 'moveWidgetInstance' }), setAlignment: (options) => dispatch({ ...options, type: 'setWidgetInstanceAlignment' }), diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index 4f6d1489f9a..ab5119a53c8 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -562,7 +562,7 @@ describe('workbench widget region defaults', () => { } }); - it('adopts the Layers-only Edit rail for untouched legacy rails and leaves customized rails alone', () => { + it('adopts the shipped Edit rail for untouched legacy rails and leaves customized rails alone', () => { const initial = createInitialWorkbenchState(); const withRight = (instanceIds: Project['widgetRegions']['right']['instanceIds']): WorkbenchState => ({ ...initial, @@ -575,6 +575,7 @@ describe('workbench widget region defaults', () => { })), }); const legacyEditRail = withRight(['layers', 'preview', 'gallery', 'image-map', 'queue']); + const layersOnlyRail = withRight(['layers']); const custom = withRight(['image-map', 'layers']); const hydratedLegacy = getActiveProject( @@ -582,7 +583,17 @@ describe('workbench widget region defaults', () => { ); expect(hydratedLegacy.widgetRegions.right).toMatchObject({ activeInstanceId: 'layers', - instanceIds: ['layers'], + instanceIds: ['layers', 'preview'], + }); + + // The brief Layers-only rail (which dropped the preview) is a shipped + // shape too, so it adopts the current rail and gets the preview back. + const hydratedLayersOnly = getActiveProject( + workbenchReducer(initial, { state: layersOnlyRail, type: 'hydrateWorkbench' }) + ); + expect(hydratedLayersOnly.widgetRegions.right).toMatchObject({ + activeInstanceId: 'layers', + instanceIds: ['layers', 'preview'], }); const hydratedCustom = getActiveProject(workbenchReducer(initial, { state: custom, type: 'hydrateWorkbench' })); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index dbc190d0e93..006538af93c 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -242,7 +242,12 @@ type WorkbenchReducerAction = | { type: 'setWidgetInstanceAlignment'; region: WidgetRegion; instanceId: WidgetInstanceId; align: 'start' | 'end' } | { type: 'setRegionWidgetCollapsed'; region: WidgetRegion; isCollapsed: boolean } | { type: 'setRegionWidgetSize'; region: WidgetRegion; sizePx: number } - | { type: 'floatWidget'; instanceId: WidgetInstanceId } + | { + type: 'floatWidget'; + instanceId: WidgetInstanceId; + /** The chrome the float was asked from; docking returns the window there. */ + region?: WidgetRegion; + } | { type: 'dockFloatingWidget'; instanceId: WidgetInstanceId } | { type: 'setFloatingWidgetGeometry'; @@ -1305,15 +1310,16 @@ const ensureRightRegion = (rightRegion: WidgetRegionState | undefined): WidgetRe }; /** - * Every Edit rail this app shipped as a default while the canvas editors were - * separate widgets: the tabbed rail, and one unreleased build's variant - * without Image Map. Those editors are panes of the Layers panel now, so an - * untouched rail of either shape adopts the shipped Layers-only rail; a + * Every Edit rail this app shipped as a default: the tabbed rail from while + * the canvas editors were separate widgets, one unreleased build's variant + * without Image Map, and the brief Layers-only rail that dropped the preview. + * An untouched rail of any of those shapes adopts the shipped rail; a * customized rail stays the user's. */ const LEGACY_EDIT_RIGHT_REGION_WIDGET_IDS: ReadonlyArray = [ ['layers', 'preview', 'gallery', 'image-map', 'queue'], ['layers', 'preview', 'gallery', 'queue'], + ['layers'], ]; const sameInstanceIds = (region: WidgetRegionState, ids: readonly WidgetInstanceId[]): boolean => @@ -1405,9 +1411,18 @@ const ensureCenterRegion = ( fallbackCenterViewId: CenterViewId ): WidgetRegionState => { const defaultCenterRegion = createWidgetRegions().center; + // A center with no region data at all adopts the default arrangement, but an + // explicitly emptied one is authoritative: the last view may be floating in a + // window (the surface falls back until its dock control returns it), and + // refilling it would inject views the project never placed. + const instanceIds = centerRegion ? centerRegion.instanceIds : defaultCenterRegion.instanceIds; const activeInstanceId = centerRegion?.activeInstanceId ?? getCenterWidgetIdFromViewId(fallbackCenterViewId); - const instanceIds = centerRegion?.instanceIds.length ? centerRegion.instanceIds : defaultCenterRegion.instanceIds; - const normalizedActiveInstanceId = instanceIds.includes(activeInstanceId) ? activeInstanceId : instanceIds[0]; + // A pointer that names none of the members is clamped — but an emptied + // center keeps its pointer, which names the instance now floating in a + // window; the boot preload reads it to have that window's chunk ready. + const normalizedActiveInstanceId = instanceIds.includes(activeInstanceId) + ? activeInstanceId + : (instanceIds[0] ?? activeInstanceId); return { ...defaultCenterRegion, @@ -1517,9 +1532,10 @@ const normalizeFloatingWidgets = ( * reload they hand back a widget the person had floated. Floating wins: it is * the deliberate act, while the region entry is the migration's guess. * - * The center region is the exception, because it must always hold a view. If - * honouring the floating entries would empty it, they lose and the widget - * stays docked. + * That holds for the center too, even when its last view is the one floating: + * the surface falls back to the center's fallback view, and the window's dock + * control is one click from restoring it. Only the destructive placements + * (`toggleRegionWidget`, `closeWidgetPlacement`) still refuse to empty it. */ const reconcileFloatingWidgets = ( widgetRegions: Record, @@ -1543,13 +1559,6 @@ const reconcileFloatingWidgets = ( continue; } - if (regionId === 'center' && instanceIds.length === 0) { - remainingFloating = Object.fromEntries( - Object.entries(remainingFloating).filter(([instanceId]) => !region.instanceIds.includes(instanceId)) - ); - continue; - } - reconciledRegions[regionId] = { ...region, activeInstanceId: instanceIds.includes(region.activeInstanceId) @@ -3627,10 +3636,16 @@ export const __workbenchReducerInternal = ( return updateProjectById(state, action.projectId ?? state.activeProjectId, (project) => { const region = project.widgetRegions[action.region]; + // Selecting a slot names the instance as the region's shown surface, so + // it docks: an instance must never render in a panel and a floating + // window at once — the same rule `openRegionWidget` enforces. + const { [action.widgetId]: _floated, ...floatingWidgets } = project.floatingWidgets ?? {}; + if (action.region === 'center') { return applyAutoRouteForRevealedInstance( { ...project, + floatingWidgets, widgetRegions: { ...project.widgetRegions, center: { ...region, activeInstanceId: action.widgetId, isCollapsed: false }, @@ -3648,6 +3663,7 @@ export const __workbenchReducerInternal = ( if (region.activeInstanceId === action.widgetId) { const disclosed = { ...project, + floatingWidgets, layout: openPanelForRegion(project.layout, action.region), widgetRegions: { ...project.widgetRegions, @@ -3663,6 +3679,7 @@ export const __workbenchReducerInternal = ( return applyAutoRouteForRevealedInstance( { ...project, + floatingWidgets, layout: openPanelForRegion(project.layout, action.region), widgetRegions: { ...project.widgetRegions, @@ -3718,23 +3735,32 @@ export const __workbenchReducerInternal = ( return project; } - const hostEntry = (Object.entries(project.widgetRegions) as [WidgetRegion, WidgetRegionState][]).find( - ([, region]) => region.instanceIds.includes(action.instanceId) - ); + // One instance may be a member of several regions (the preview is + // placed in the center and a rail by default), so the region the float + // was asked from — the chrome whose button was clicked — decides where + // the window docks back to. The unhinted fallback takes the first + // member region in the region map's order, which persisted projects + // do not agree on. + const findHost = (match: (regionId: WidgetRegion, region: WidgetRegionState) => boolean) => + (Object.entries(project.widgetRegions) as [WidgetRegion, WidgetRegionState][]).find(([regionId, region]) => + match(regionId, region) + ); + const hostEntry = action.region + ? findHost((regionId, region) => regionId === action.region && region.instanceIds.includes(action.instanceId)) + : undefined; + const resolvedHostEntry = hostEntry ?? findHost((_, region) => region.instanceIds.includes(action.instanceId)); - if (!hostEntry || !project.widgetInstances[action.instanceId]) { + if (!resolvedHostEntry || !project.widgetInstances[action.instanceId]) { return project; } - const [hostRegionId, hostRegion] = hostEntry; - - // The work surface must keep a view. `toggleRegionWidget` and - // `closeWidgetPlacement` refuse the same removal; floating it out is - // the same removal with a window attached. - if (hostRegionId === 'center' && hostRegion.instanceIds.length === 1) { - return project; - } + const [hostRegionId, hostRegion] = resolvedHostEntry; + // Floating may empty the center, unlike `toggleRegionWidget` and + // `closeWidgetPlacement`, which still refuse the same removal: those + // discard the view outright, while a float keeps it one dock click + // away, and the emptied surface falls back to the center's fallback + // view rather than standing blank. const instanceIds = hostRegion.instanceIds.filter((instanceId) => instanceId !== action.instanceId); const fallbackInstanceId = getNextInstanceId(hostRegion, action.instanceId); const floating: FloatingWidgetState = { @@ -3760,8 +3786,9 @@ export const __workbenchReducerInternal = ( instanceIds, // Floating the last widget out of a rail leaves nothing to show, // so the rail collapses rather than standing open and empty — - // the same repair `toggleRegionWidget` makes. - isCollapsed: instanceIds.length === 0 ? true : hostRegion.isCollapsed, + // the same repair `toggleRegionWidget` makes. The center has no + // collapsed state; its fallback view carries the empty surface. + isCollapsed: instanceIds.length === 0 && hostRegionId !== 'center' ? true : hostRegion.isCollapsed, }, }, }, diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts index 779f3fff3ac..2df40cd1d69 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -401,7 +401,9 @@ const createCommands = ( }, widgets: { dockFloating: command('dockFloatingWidget', (instanceId: string) => ({ instanceId })), - float: command('floatWidget', (instanceId: string) => ({ instanceId })), + float: command('floatWidget', (instanceId: string, region?: ActionPayload<'floatWidget'>['region']) => + region ? { instanceId, region } : { instanceId } + ), focusFloating: command('focusFloatingWidget', (instanceId: string) => ({ instanceId })), move: command('moveWidgetInstance'), open: command('openRegionWidget'),