diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx
new file mode 100644
index 0000000000..73f50ca36f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx
@@ -0,0 +1,130 @@
+/* eslint-disable i18next/no-literal-string */
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { ContextMenu, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
+import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress';
+import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask';
+import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus';
+import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems';
+import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems';
+import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
+import { Filter } from 'features/controlLayers/components/Filters/Filter';
+import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
+import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
+import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
+import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
+import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
+import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
+import { Transform } from 'features/controlLayers/components/Transform/Transform';
+import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
+import type { AdvancedSessionIdentifier } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import { memo, useCallback } from 'react';
+import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
+
+const FOCUS_REGION_STYLES: SystemStyleObject = {
+ width: 'full',
+ height: 'full',
+};
+
+const MenuContent = memo(() => {
+ return (
+
+
+
+
+
+
+ );
+});
+MenuContent.displayName = 'MenuContent';
+
+const canvasBgSx = {
+ position: 'relative',
+ w: 'full',
+ h: 'full',
+ borderRadius: 'base',
+ overflow: 'hidden',
+ bg: 'base.900',
+ '&[data-dynamic-grid="true"]': {
+ bg: 'base.850',
+ },
+};
+
+export const AdvancedSession = memo((_props: { session: AdvancedSessionIdentifier }) => {
+ const dynamicGrid = useAppSelector(selectDynamicGrid);
+ const showHUD = useAppSelector(selectShowHUD);
+
+ const renderMenu = useCallback(() => {
+ return ;
+ }, []);
+
+ return (
+
+
+
+
+
+ renderMenu={renderMenu} withLongPress={false}>
+ {(ref) => (
+
+
+
+
+ {showHUD && }
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+AdvancedSession.displayName = 'AdvancedSession';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx
index b0ba682988..98b7bf3a5b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx
@@ -1,991 +1,27 @@
-/* eslint-disable i18next/no-literal-string */
-import type {
- ButtonGroupProps,
- CircularProgressProps,
- ImageProps,
- SystemStyleObject,
- TextProps,
-} from '@invoke-ai/ui-library';
-import {
- Button,
- ButtonGroup,
- CircularProgress,
- ContextMenu,
- Divider,
- Flex,
- FormControl,
- FormLabel,
- Heading,
- IconButton,
- Image,
- Menu,
- MenuButton,
- MenuList,
- Spacer,
- Switch,
- Text,
- Tooltip,
-} from '@invoke-ai/ui-library';
-import { useStore } from '@nanostores/react';
-import { skipToken } from '@reduxjs/toolkit/query';
-import { EMPTY_ARRAY } from 'app/store/constants';
-import { useAppStore } from 'app/store/nanostores/store';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
-import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
-import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
-import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask';
-import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus';
-import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo';
-import { CanvasBusySpinner } from 'features/controlLayers/components/CanvasBusySpinner';
-import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems';
-import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems';
-import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
-import { Filter } from 'features/controlLayers/components/Filters/Filter';
-import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
-import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
-import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
-import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
-import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
-import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
-import { Transform } from 'features/controlLayers/components/Transform/Transform';
-import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
-import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
-import { canvasSessionStarted, selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice';
-import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
-import { DndDropTarget } from 'features/dnd/DndDropTarget';
-import { DndImage } from 'features/dnd/DndImage';
-import { newCanvasFromImage } from 'features/imageActions/actions';
-import { isImageField } from 'features/nodes/types/common';
-import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils';
-import { round } from 'lodash-es';
-import { atom, type WritableAtom } from 'nanostores';
-import type { ChangeEvent } from 'react';
-import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { useHotkeys } from 'react-hotkeys-hook';
-import { Trans, useTranslation } from 'react-i18next';
-import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi';
-import { useGetImageDTOQuery } from 'services/api/endpoints/images';
-import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue';
-import type { ImageDTO, S } from 'services/api/types';
-import type { ProgressData } from 'services/events/stores';
-import { $socket, setProgress, useProgressData } from 'services/events/stores';
-import type { Equals, Param0 } from 'tsafe';
-import { assert, objectEntries } from 'tsafe';
-
-import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress';
-
-const FOCUS_REGION_STYLES: SystemStyleObject = {
- width: 'full',
- height: 'full',
-};
-
-const MenuContent = memo(() => {
- return (
-
-
-
-
-
-
- );
-});
-MenuContent.displayName = 'MenuContent';
+import { useAppSelector } from 'app/store/storeHooks';
+import { AdvancedSession } from 'features/controlLayers/components/AdvancedSession/AdvancedSession';
+import { NoSession } from 'features/controlLayers/components/NoSession/NoSession';
+import { SimpleSession } from 'features/controlLayers/components/SimpleSession/SimpleSession';
+import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import { memo } from 'react';
+import type { Equals } from 'tsafe';
+import { assert } from 'tsafe';
export const CanvasMainPanelContent = memo(() => {
const session = useAppSelector(selectCanvasSession);
if (session === null) {
- return ;
+ return ;
}
if (session.type === 'simple') {
- return ;
+ return ;
}
if (session.type === 'advanced') {
- return ;
+ return ;
}
assert>(false, 'Unexpected session');
});
CanvasMainPanelContent.displayName = 'CanvasMainPanelContent';
-
-const StagingAreaWrapper = memo(({ id }: { id: string }) => {
- const ctx = useMemo(
- () =>
- ({
- session: {
- type: 'simple',
- id,
- },
- $progressData: atom>({}),
- }) as const,
- [id]
- );
-
- return (
-
-
-
- );
-});
-StagingAreaWrapper.displayName = 'StagingAreaWrapper';
-
-const generateWithStartingImageDndTargetData = newCanvasFromImageDndTarget.getData({
- type: 'raster_layer',
- withResize: true,
-});
-const generateWithStartingImageAndInpaintMaskDndTargetData = newCanvasFromImageDndTarget.getData({
- type: 'raster_layer',
- withInpaintMask: true,
-});
-const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getData({
- type: 'control_layer',
- withResize: true,
-});
-
-const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
-
-const NoActiveSession = memo(() => {
- const { t } = useTranslation();
- const dispatch = useAppDispatch();
- const newSesh = useCallback(() => {
- dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
- }, [dispatch]);
-
- return (
-
- Get Started with Invoke
-
- or
-
-
-
-
-
-
- );
-});
-NoActiveSession.displayName = 'NoActiveSession';
-
-const GenerateWithStartingImage = memo(() => {
- const { t } = useTranslation();
- const { getState, dispatch } = useAppStore();
- const useImageUploadButtonOptions = useMemo>(
- () => ({
- onUpload: (imageDTO: ImageDTO) => {
- newCanvasFromImage({ imageDTO, type: 'raster_layer', withResize: true, getState, dispatch });
- },
- allowMultiple: false,
- }),
- [dispatch, getState]
- );
- const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
- const components = useMemo(
- () => ({
- UploadButton: (
- }
- />
- ),
- }),
- [uploadApi]
- );
-
- return (
-
-
- Generate with a Starting Image
-
- Regenerate the starting image using the model (Image to Image).
-
-
-
-
-
-
- );
-});
-GenerateWithStartingImage.displayName = 'GenerateWithStartingImage';
-
-const GenerateWithControlImage = memo(() => {
- const { t } = useTranslation();
- const { getState, dispatch } = useAppStore();
- const useImageUploadButtonOptions = useMemo>(
- () => ({
- onUpload: (imageDTO: ImageDTO) => {
- newCanvasFromImage({ imageDTO, type: 'control_layer', withResize: true, getState, dispatch });
- },
- allowMultiple: false,
- }),
- [dispatch, getState]
- );
- const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
- const components = useMemo(
- () => ({
- UploadButton: (
- }
- />
- ),
- }),
- [uploadApi]
- );
-
- return (
-
-
- Generate with a Control Image
-
-
- Generate a new image using the control image to guide the structure and composition (Text to Image with
- Control).
-
-
-
-
-
-
-
- );
-});
-GenerateWithControlImage.displayName = 'GenerateWithControlImage';
-
-const GenerateWithStartingImageAndInpaintMask = memo(() => {
- const { t } = useTranslation();
- const { getState, dispatch } = useAppStore();
- const useImageUploadButtonOptions = useMemo>(
- () => ({
- onUpload: (imageDTO: ImageDTO) => {
- newCanvasFromImage({ imageDTO, type: 'raster_layer', withInpaintMask: true, getState, dispatch });
- },
- allowMultiple: false,
- }),
- [dispatch, getState]
- );
- const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
- const components = useMemo(
- () => ({
- UploadButton: (
- }
- />
- ),
- }),
- [uploadApi]
- );
-
- return (
-
-
- Edit Image
-
- Edit the image by regenerating parts of it (Inpaint).
-
-
-
-
-
-
- );
-});
-GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask';
-
-type StagingContextValue = {
- session:
- | {
- type: 'simple';
- id: string;
- }
- | {
- type: 'advanced';
- id: string;
- };
- $progressData: WritableAtom>;
-};
-
-const StagingContext = createContext(null);
-
-const useStagingContext = () => {
- const ctx = useContext(StagingContext);
- assert(ctx !== null, 'use in stg prov');
- return ctx;
-};
-
-const useStagingAreaKeyboardNav = (
- items: S['SessionQueueItem'][],
- selectedItemId: number | null,
- onSelectItemId: (item_id: number) => void
-) => {
- const onNext = useCallback(() => {
- if (selectedItemId === null) {
- return;
- }
- const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
- const nextIndex = (currentIndex + 1) % items.length;
- const nextItem = items[nextIndex];
- if (!nextItem) {
- return;
- }
- onSelectItemId(nextItem.item_id);
- }, [items, onSelectItemId, selectedItemId]);
- const onPrev = useCallback(() => {
- if (selectedItemId === null) {
- return;
- }
- const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
- const prevIndex = (currentIndex - 1 + items.length) % items.length;
- const prevItem = items[prevIndex];
- if (!prevItem) {
- return;
- }
- onSelectItemId(prevItem.item_id);
- }, [items, onSelectItemId, selectedItemId]);
-
- const onFirst = useCallback(() => {
- const first = items.at(0);
- if (!first) {
- return;
- }
- onSelectItemId(first.item_id);
- }, [items, onSelectItemId]);
- const onLast = useCallback(() => {
- const last = items.at(-1);
- if (!last) {
- return;
- }
- onSelectItemId(last.item_id);
- }, [items, onSelectItemId]);
-
- useHotkeys('left', onPrev, { preventDefault: true });
- useHotkeys('right', onNext, { preventDefault: true });
- useHotkeys('meta+left', onFirst, { preventDefault: true });
- useHotkeys('meta+right', onLast, { preventDefault: true });
-};
-
-const LIST_ALL_OPTIONS = {
- selectFromResult: ({ data }) => {
- if (!data) {
- return { items: EMPTY_ARRAY };
- }
- return { items: data.filter(({ status }) => status !== 'canceled') };
- },
-} satisfies Parameters[1];
-
-const StagingArea = memo(() => {
- const ctx = useStagingContext();
- const [selectedItemId, setSelectedItemId] = useState(null);
- const [autoSwitch, setAutoSwitch] = useState(true);
- const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS);
- const selectedItem = useMemo(() => {
- if (items.length === 0) {
- return null;
- }
- if (selectedItemId === null) {
- return null;
- }
- return items.find(({ item_id }) => item_id === selectedItemId) ?? null;
- }, [items, selectedItemId]);
- const selectedItemIndex = useMemo(() => {
- if (items.length === 0) {
- return null;
- }
- if (selectedItemId === null) {
- return null;
- }
- return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null;
- }, [items, selectedItemId]);
-
- const onSelectItemId = useCallback((item_id: number | null) => {
- setSelectedItemId(item_id);
- if (item_id !== null) {
- document.getElementById(getCardId(item_id))?.scrollIntoView();
- }
- }, []);
-
- useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId);
-
- useEffect(() => {
- if (items.length === 0) {
- onSelectItemId(null);
- return;
- }
- if (selectedItemId === null && items.length > 0) {
- onSelectItemId(items[0]?.item_id ?? null);
- return;
- }
- }, [items, onSelectItemId, selectedItem, selectedItemId]);
-
- const socket = useStore($socket);
- useEffect(() => {
- if (!socket) {
- return;
- }
-
- const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
- if (data.destination !== ctx.session.id) {
- return;
- }
- if (data.status === 'in_progress' && autoSwitch) {
- onSelectItemId(data.item_id);
- }
- };
-
- socket.on('queue_item_status_changed', onQueueItemStatusChanged);
-
- return () => {
- socket.off('queue_item_status_changed', onQueueItemStatusChanged);
- };
- }, [autoSwitch, ctx.$progressData, ctx.session.id, onSelectItemId, socket]);
-
- useEffect(() => {
- if (!socket) {
- return;
- }
- const onProgress = (data: S['InvocationProgressEvent']) => {
- if (data.destination !== ctx.session.id) {
- return;
- }
- setProgress(ctx.$progressData, data);
- };
- socket.on('invocation_progress', onProgress);
-
- return () => {
- socket.off('invocation_progress', onProgress);
- };
- }, [ctx.$progressData, ctx.session.id, socket]);
-
- return (
-
-
-
- {items.length > 0 && (
-
- )}
- {items.length === 0 && (
-
- No generations
-
- )}
-
- );
-});
-StagingArea.displayName = 'StagingArea';
-
-const StagingAreaContent = memo(
- ({
- items,
- selectedItem,
- selectedItemId,
- selectedItemIndex,
- onChangeAutoSwitch,
- onSelectItemId,
- }: {
- items: S['SessionQueueItem'][];
- selectedItem: S['SessionQueueItem'] | null;
- selectedItemId: number | null;
- selectedItemIndex: number | null;
- onChangeAutoSwitch: (autoSwitch: boolean) => void;
- onSelectItemId: (itemId: number) => void;
- }) => {
- return (
- <>
-
- {selectedItem && selectedItemIndex !== null && (
-
- )}
- {!selectedItem && No generation selected}
-
-
-
-
-
- {items.map((item, i) => (
-
- ))}
-
-
-
- >
- );
- }
-);
-StagingAreaContent.displayName = 'StagingAreaContent';
-
-const StagingAreaHeader = memo(
- ({ autoSwitch, setAutoSwitch }: { autoSwitch: boolean; setAutoSwitch: (autoSwitch: boolean) => void }) => {
- const dispatch = useAppDispatch();
-
- const startOver = useCallback(() => {
- dispatch(canvasSessionStarted({ sessionType: 'simple' }));
- }, [dispatch]);
-
- const onChangeAutoSwitch = useCallback(
- (e: ChangeEvent) => {
- setAutoSwitch(e.target.checked);
- },
- [setAutoSwitch]
- );
-
- return (
-
-
- Generations
-
-
-
- Auto-switch
-
-
-
-
- );
- }
-);
-StagingAreaHeader.displayName = 'StagingAreaHeader';
-
-const miniQueueItemSx = {
- cursor: 'pointer',
- userSelect: 'none',
- pos: 'relative',
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
- h: 'full',
- maxH: 'full',
- maxW: 'full',
- minW: 0,
- minH: 0,
- borderWidth: 1,
- borderRadius: 'base',
- '&[data-selected="true"]': {
- borderColor: 'invokeBlue.300',
- },
- aspectRatio: '1/1',
- flexShrink: 0,
-} satisfies SystemStyleObject;
-
-const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`;
-
-const getOutputImageName = (item: S['SessionQueueItem']) => {
- const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>
- isCanvasOutputNodeId(nodeId)
- )?.[1][0];
- const output = nodeId ? item.session.results[nodeId] : undefined;
-
- if (!output) {
- return null;
- }
-
- for (const [_name, value] of objectEntries(output)) {
- if (isImageField(value)) {
- return value.image_name;
- }
- }
-
- return null;
-};
-
-const useOutputImageDTO = (item: S['SessionQueueItem']) => {
- const outputImageName = useMemo(() => getOutputImageName(item), [item]);
-
- const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken);
-
- return imageDTO;
-};
-
-type MiniQueueItemProps = {
- item: S['SessionQueueItem'];
- number: number;
- isSelected: boolean;
- onSelectItemId: (item_id: number) => void;
- onChangeAutoSwitch: (autoSwitch: boolean) => void;
-};
-
-const MiniQueueItem = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: MiniQueueItemProps) => {
- const [imageLoaded, setImageLoaded] = useState(false);
- const imageDTO = useOutputImageDTO(item);
-
- const onClick = useCallback(() => {
- onSelectItemId(item.item_id);
- }, [item.item_id, onSelectItemId]);
-
- const onDoubleClick = useCallback(() => {
- onChangeAutoSwitch(item.status === 'in_progress');
- }, [item.status, onChangeAutoSwitch]);
-
- const onLoad = useCallback(() => {
- setImageLoaded(true);
- }, []);
-
- return (
-
-
- {imageDTO && }
- {!imageLoaded && }
-
-
-
- );
-});
-MiniQueueItem.displayName = 'MiniQueueItem';
-
-const fullSizeQueueItemSx = {
- cursor: 'pointer',
- userSelect: 'none',
- pos: 'relative',
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
- h: 'full',
- w: 'full',
-} satisfies SystemStyleObject;
-
-type FullSizeQueueItemProps = {
- item: S['SessionQueueItem'];
- number: number;
-};
-
-const FullSizeQueueItem = memo(({ item, number }: FullSizeQueueItemProps) => {
- const imageDTO = useOutputImageDTO(item);
- const [imageLoaded, setImageLoaded] = useState(false);
-
- const onLoad = useCallback(() => {
- setImageLoaded(true);
- }, []);
-
- return (
-
-
- {imageDTO && }
- {!imageLoaded && }
-
-
-
-
- );
-});
-FullSizeQueueItem.displayName = 'FullSizeQueueItem';
-
-const ProgressImage = memo(({ session_id, ...rest }: { session_id: string } & ImageProps) => {
- const { $progressData } = useStagingContext();
- const { progressImage } = useProgressData($progressData, session_id);
-
- if (!progressImage) {
- return null;
- }
-
- return (
-
- );
-});
-ProgressImage.displayName = 'ProgressImage';
-
-const getMessage = (data: S['InvocationProgressEvent']) => {
- let message = data.message;
- if (data.percentage) {
- message += ` (${round(data.percentage * 100)}%)`;
- }
- return message;
-};
-
-const ItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => {
- return {`#${number}`};
-});
-ItemNumber.displayName = 'ItemNumber';
-
-const ProgressMessage = memo(
- ({ session_id, status, ...rest }: { session_id: string; status: S['SessionQueueItem']['status'] } & TextProps) => {
- const { $progressData } = useStagingContext();
- const { progressEvent } = useProgressData($progressData, session_id);
-
- if (status === 'completed' || status === 'failed' || status === 'canceled') {
- return null;
- }
-
- return (
-
- {progressEvent ? getMessage(progressEvent) : 'Waiting to start...'}
-
- );
- }
-);
-ProgressMessage.displayName = 'ProgressMessage';
-
-const ProgressLabel = memo(({ status, ...rest }: { status: S['SessionQueueItem']['status'] } & TextProps) => {
- if (status === 'pending') {
- return (
-
- Pending
-
- );
- }
- if (status === 'canceled') {
- return (
-
- Canceled
-
- );
- }
- if (status === 'failed') {
- return (
-
- Failed
-
- );
- }
-
- if (status === 'in_progress') {
- return (
-
- In Progress
-
- );
- }
-
- return null;
-});
-ProgressLabel.displayName = 'ProgressLabel';
-
-const circleStyles: SystemStyleObject = {
- circle: {
- transitionProperty: 'none',
- transitionDuration: '0s',
- },
- position: 'absolute',
- top: 2,
- right: 2,
-};
-
-const ProgressCircle = memo(
- ({
- session_id,
- status,
- ...rest
- }: { session_id: string; status: S['SessionQueueItem']['status'] } & CircularProgressProps) => {
- const { $progressData } = useStagingContext();
- const { progressEvent } = useProgressData($progressData, session_id);
-
- if (status !== 'in_progress') {
- return null;
- }
-
- return (
-
-
-
- );
- }
-);
-ProgressCircle.displayName = 'ProgressCircle';
-
-const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => {
- const { getState, dispatch } = useAppStore();
-
- const vary = useCallback(() => {
- newCanvasFromImage({
- imageDTO,
- type: 'raster_layer',
- withResize: true,
- getState,
- dispatch,
- });
- }, [dispatch, getState, imageDTO]);
-
- const useAsControl = useCallback(() => {
- newCanvasFromImage({
- imageDTO,
- type: 'control_layer',
- withResize: true,
- getState,
- dispatch,
- });
- }, [dispatch, getState, imageDTO]);
-
- const edit = useCallback(() => {
- newCanvasFromImage({
- imageDTO,
- type: 'raster_layer',
- withInpaintMask: true,
- getState,
- dispatch,
- });
- }, [dispatch, getState, imageDTO]);
- return (
-
-
-
-
-
- );
-});
-ImageActions.displayName = 'ImageActions';
-
-const canvasBgSx = {
- position: 'relative',
- w: 'full',
- h: 'full',
- borderRadius: 'base',
- overflow: 'hidden',
- bg: 'base.900',
- '&[data-dynamic-grid="true"]': {
- bg: 'base.850',
- },
-};
-
-const CanvasActiveSession = memo(() => {
- const dynamicGrid = useAppSelector(selectDynamicGrid);
- const showHUD = useAppSelector(selectShowHUD);
-
- const renderMenu = useCallback(() => {
- return ;
- }, []);
-
- return (
-
-
-
-
-
- renderMenu={renderMenu} withLongPress={false}>
- {(ref) => (
-
-
-
-
- {showHUD && }
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-});
-CanvasActiveSession.displayName = 'ActiveCanvasContent';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx
new file mode 100644
index 0000000000..14b253330c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx
@@ -0,0 +1,68 @@
+/* eslint-disable i18next/no-literal-string */
+
+import { Button, Flex, Text } from '@invoke-ai/ui-library';
+import { useAppStore } from 'app/store/nanostores/store';
+import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
+import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { newCanvasFromImage } from 'features/imageActions/actions';
+import { memo, useMemo } from 'react';
+import { Trans } from 'react-i18next';
+import { PiUploadBold } from 'react-icons/pi';
+import type { ImageDTO } from 'services/api/types';
+import type { Param0 } from 'tsafe';
+
+const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getData({
+ type: 'control_layer',
+ withResize: true,
+});
+
+export const GenerateWithControlImage = memo(() => {
+ const { getState, dispatch } = useAppStore();
+ const useImageUploadButtonOptions = useMemo>(
+ () => ({
+ onUpload: (imageDTO: ImageDTO) => {
+ newCanvasFromImage({ imageDTO, type: 'control_layer', withResize: true, getState, dispatch });
+ },
+ allowMultiple: false,
+ }),
+ [dispatch, getState]
+ );
+ const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
+ const components = useMemo(
+ () => ({
+ UploadButton: (
+ }
+ />
+ ),
+ }),
+ [uploadApi]
+ );
+
+ return (
+
+
+ Generate with a Control Image
+
+
+ Generate a new image using the control image to guide the structure and composition (Text to Image with
+ Control).
+
+
+
+
+
+
+
+ );
+});
+GenerateWithControlImage.displayName = 'GenerateWithControlImage';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImage.tsx
new file mode 100644
index 0000000000..308bf2a5af
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImage.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable i18next/no-literal-string */
+
+import { Button, Flex, Text } from '@invoke-ai/ui-library';
+import { useAppStore } from 'app/store/nanostores/store';
+import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
+import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { newCanvasFromImage } from 'features/imageActions/actions';
+import { memo, useMemo } from 'react';
+import { Trans } from 'react-i18next';
+import { PiUploadBold } from 'react-icons/pi';
+import type { ImageDTO } from 'services/api/types';
+import type { Param0 } from 'tsafe';
+
+const generateWithStartingImageDndTargetData = newCanvasFromImageDndTarget.getData({
+ type: 'raster_layer',
+ withResize: true,
+});
+
+export const GenerateWithStartingImage = memo(() => {
+ const { getState, dispatch } = useAppStore();
+ const useImageUploadButtonOptions = useMemo>(
+ () => ({
+ onUpload: (imageDTO: ImageDTO) => {
+ newCanvasFromImage({ imageDTO, type: 'raster_layer', withResize: true, getState, dispatch });
+ },
+ allowMultiple: false,
+ }),
+ [dispatch, getState]
+ );
+ const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
+ const components = useMemo(
+ () => ({
+ UploadButton: (
+ }
+ />
+ ),
+ }),
+ [uploadApi]
+ );
+
+ return (
+
+
+ Generate with a Starting Image
+
+ Regenerate the starting image using the model (Image to Image).
+
+
+
+
+
+
+ );
+});
+GenerateWithStartingImage.displayName = 'GenerateWithStartingImage';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImageAndInpaintMask.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImageAndInpaintMask.tsx
new file mode 100644
index 0000000000..c5220e3304
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImageAndInpaintMask.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable i18next/no-literal-string */
+
+import { Button, Flex, Text } from '@invoke-ai/ui-library';
+import { useAppStore } from 'app/store/nanostores/store';
+import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
+import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { newCanvasFromImage } from 'features/imageActions/actions';
+import { memo, useMemo } from 'react';
+import { Trans } from 'react-i18next';
+import { PiUploadBold } from 'react-icons/pi';
+import type { ImageDTO } from 'services/api/types';
+import type { Param0 } from 'tsafe';
+
+const generateWithStartingImageAndInpaintMaskDndTargetData = newCanvasFromImageDndTarget.getData({
+ type: 'raster_layer',
+ withInpaintMask: true,
+});
+
+export const GenerateWithStartingImageAndInpaintMask = memo(() => {
+ const { getState, dispatch } = useAppStore();
+ const useImageUploadButtonOptions = useMemo>(
+ () => ({
+ onUpload: (imageDTO: ImageDTO) => {
+ newCanvasFromImage({ imageDTO, type: 'raster_layer', withInpaintMask: true, getState, dispatch });
+ },
+ allowMultiple: false,
+ }),
+ [dispatch, getState]
+ );
+ const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
+ const components = useMemo(
+ () => ({
+ UploadButton: (
+ }
+ />
+ ),
+ }),
+ [uploadApi]
+ );
+
+ return (
+
+
+ Edit Image
+
+ Edit the image by regenerating parts of it (Inpaint).
+
+
+
+
+
+
+ );
+});
+GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx
new file mode 100644
index 0000000000..4586b89573
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx
@@ -0,0 +1,32 @@
+/* eslint-disable i18next/no-literal-string */
+
+import { Button, Flex, Heading, Text } from '@invoke-ai/ui-library';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { GenerateWithControlImage } from 'features/controlLayers/components/NoSession/GenerateWithControlImage';
+import { GenerateWithStartingImage } from 'features/controlLayers/components/NoSession/GenerateWithStartingImage';
+import { GenerateWithStartingImageAndInpaintMask } from 'features/controlLayers/components/NoSession/GenerateWithStartingImageAndInpaintMask';
+import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import { memo, useCallback } from 'react';
+
+export const NoSession = memo(() => {
+ const dispatch = useAppDispatch();
+ const newSesh = useCallback(() => {
+ dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
+ }, [dispatch]);
+
+ return (
+
+ Get Started with Invoke
+
+ or
+
+
+
+
+
+
+ );
+});
+NoSession.displayName = 'NoSession';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx
new file mode 100644
index 0000000000..96d27b8d14
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx
@@ -0,0 +1,55 @@
+/* eslint-disable i18next/no-literal-string */
+import type { ButtonGroupProps } from '@invoke-ai/ui-library';
+import { Button, ButtonGroup } from '@invoke-ai/ui-library';
+import { useAppStore } from 'app/store/nanostores/store';
+import { newCanvasFromImage } from 'features/imageActions/actions';
+import { memo, useCallback } from 'react';
+import type { ImageDTO } from 'services/api/types';
+
+export const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => {
+ const { getState, dispatch } = useAppStore();
+
+ const vary = useCallback(() => {
+ newCanvasFromImage({
+ imageDTO,
+ type: 'raster_layer',
+ withResize: true,
+ getState,
+ dispatch,
+ });
+ }, [dispatch, getState, imageDTO]);
+
+ const useAsControl = useCallback(() => {
+ newCanvasFromImage({
+ imageDTO,
+ type: 'control_layer',
+ withResize: true,
+ getState,
+ dispatch,
+ });
+ }, [dispatch, getState, imageDTO]);
+
+ const edit = useCallback(() => {
+ newCanvasFromImage({
+ imageDTO,
+ type: 'raster_layer',
+ withInpaintMask: true,
+ getState,
+ dispatch,
+ });
+ }, [dispatch, getState, imageDTO]);
+ return (
+
+
+
+
+
+ );
+});
+ImageActions.displayName = 'ImageActions';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx
new file mode 100644
index 0000000000..ebf32f8082
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx
@@ -0,0 +1,46 @@
+import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
+import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
+import { useCanvasSessionContext,useProgressData } from 'features/controlLayers/components/SimpleSession/context';
+import { getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared';
+import { memo } from 'react';
+import type { S } from 'services/api/types';
+
+const circleStyles: SystemStyleObject = {
+ circle: {
+ transitionProperty: 'none',
+ transitionDuration: '0s',
+ },
+ position: 'absolute',
+ top: 2,
+ right: 2,
+};
+
+export const QueueItemCircularProgress = memo(
+ ({
+ session_id,
+ status,
+ ...rest
+ }: { session_id: string; status: S['SessionQueueItem']['status'] } & CircularProgressProps) => {
+ const { $progressData } = useCanvasSessionContext();
+ const { progressEvent } = useProgressData($progressData, session_id);
+
+ if (status !== 'in_progress') {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+);
+QueueItemCircularProgress.displayName = 'QueueItemCircularProgress';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx
new file mode 100644
index 0000000000..33686a5c83
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx
@@ -0,0 +1,9 @@
+import type { TextProps } from '@invoke-ai/ui-library';
+import { Text } from '@invoke-ai/ui-library';
+import { DROP_SHADOW } from 'features/controlLayers/components/SimpleSession/shared';
+import { memo } from 'react';
+
+export const QueueItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => {
+ return {`#${number}`};
+});
+QueueItemNumber.displayName = 'QueueItemNumber';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx
new file mode 100644
index 0000000000..cc5085ba2a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx
@@ -0,0 +1,62 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Flex } from '@invoke-ai/ui-library';
+import { ImageActions } from 'features/controlLayers/components/SimpleSession/ImageActions';
+import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress';
+import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber';
+import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
+import { QueueItemProgressMessage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressMessage';
+import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
+import { getQueueItemElementId, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/shared';
+import { DndImage } from 'features/dnd/DndImage';
+import { memo, useCallback, useState } from 'react';
+import type { S } from 'services/api/types';
+
+type Props = {
+ item: S['SessionQueueItem'];
+ number: number;
+};
+
+const sx = {
+ cursor: 'pointer',
+ userSelect: 'none',
+ pos: 'relative',
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ h: 'full',
+ w: 'full',
+} satisfies SystemStyleObject;
+
+export const QueueItemPreviewFull = memo(({ item, number }: Props) => {
+ const imageDTO = useOutputImageDTO(item);
+ const [imageLoaded, setImageLoaded] = useState(false);
+
+ const onLoad = useCallback(() => {
+ setImageLoaded(true);
+ }, []);
+
+ return (
+
+
+ {imageDTO && }
+ {!imageLoaded && }
+ {imageDTO && imageLoaded && }
+
+
+
+
+ );
+});
+QueueItemPreviewFull.displayName = 'QueueItemPreviewFull';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx
new file mode 100644
index 0000000000..ffecb9c9b8
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx
@@ -0,0 +1,79 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Flex } from '@invoke-ai/ui-library';
+import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress';
+import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber';
+import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
+import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
+import { getQueueItemElementId, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/shared';
+import { DndImage } from 'features/dnd/DndImage';
+import { memo, useCallback, useState } from 'react';
+import type { S } from 'services/api/types';
+
+const sx = {
+ cursor: 'pointer',
+ userSelect: 'none',
+ pos: 'relative',
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ h: 'full',
+ maxH: 'full',
+ maxW: 'full',
+ minW: 0,
+ minH: 0,
+ borderWidth: 1,
+ borderRadius: 'base',
+ '&[data-selected="true"]': {
+ borderColor: 'invokeBlue.300',
+ },
+ aspectRatio: '1/1',
+ flexShrink: 0,
+} satisfies SystemStyleObject;
+
+type Props = {
+ item: S['SessionQueueItem'];
+ number: number;
+ isSelected: boolean;
+ onSelectItemId: (item_id: number) => void;
+ onChangeAutoSwitch: (autoSwitch: boolean) => void;
+};
+
+export const QueueItemPreviewMini = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: Props) => {
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const imageDTO = useOutputImageDTO(item);
+
+ const onClick = useCallback(() => {
+ onSelectItemId(item.item_id);
+ }, [item.item_id, onSelectItemId]);
+
+ const onDoubleClick = useCallback(() => {
+ onChangeAutoSwitch(item.status === 'in_progress');
+ }, [item.status, onChangeAutoSwitch]);
+
+ const onLoad = useCallback(() => {
+ setImageLoaded(true);
+ }, []);
+
+ return (
+
+
+ {imageDTO && }
+ {!imageLoaded && }
+
+
+
+ );
+});
+QueueItemPreviewMini.displayName = 'QueueItemPreviewMini';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx
new file mode 100644
index 0000000000..55e8d836b7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx
@@ -0,0 +1,28 @@
+import type { ImageProps } from '@invoke-ai/ui-library';
+import { Image } from '@invoke-ai/ui-library';
+import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
+import { memo } from 'react';
+import { useProgressData } from 'services/events/stores';
+
+export const QueueItemProgressImage = memo(({ session_id, ...rest }: { session_id: string } & ImageProps) => {
+ const { $progressData } = useCanvasSessionContext();
+ const { progressImage } = useProgressData($progressData, session_id);
+
+ if (!progressImage) {
+ return null;
+ }
+
+ return (
+
+ );
+});
+QueueItemProgressImage.displayName = 'QueueItemProgressImage';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx
new file mode 100644
index 0000000000..d287811fb3
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx
@@ -0,0 +1,34 @@
+/* eslint-disable i18next/no-literal-string */
+import type { TextProps } from '@invoke-ai/ui-library';
+import { Text } from '@invoke-ai/ui-library';
+import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
+import { DROP_SHADOW, getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared';
+import { memo } from 'react';
+import type { S } from 'services/api/types';
+import { useProgressData } from 'services/events/stores';
+
+export const QueueItemProgressMessage = memo(
+ ({ session_id, status, ...rest }: { session_id: string; status: S['SessionQueueItem']['status'] } & TextProps) => {
+ const { $progressData } = useCanvasSessionContext();
+ const { progressEvent } = useProgressData($progressData, session_id);
+
+ if (status === 'completed' || status === 'failed' || status === 'canceled') {
+ return null;
+ }
+
+ if (status === 'pending') {
+ return (
+
+ Waiting to start...
+
+ );
+ }
+
+ return (
+
+ {getProgressMessage(progressEvent)}
+
+ );
+ }
+);
+QueueItemProgressMessage.displayName = 'QueueItemProgressMessage';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx
new file mode 100644
index 0000000000..5924b63981
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx
@@ -0,0 +1,42 @@
+/* eslint-disable i18next/no-literal-string */
+import type { TextProps } from '@invoke-ai/ui-library';
+import { Text } from '@invoke-ai/ui-library';
+import { memo } from 'react';
+import type { S } from 'services/api/types';
+
+export const QueueItemStatusLabel = memo(
+ ({ status, ...rest }: { status: S['SessionQueueItem']['status'] } & TextProps) => {
+ if (status === 'pending') {
+ return (
+
+ Pending
+
+ );
+ }
+ if (status === 'canceled') {
+ return (
+
+ Canceled
+
+ );
+ }
+ if (status === 'failed') {
+ return (
+
+ Failed
+
+ );
+ }
+
+ if (status === 'in_progress') {
+ return (
+
+ In Progress
+
+ );
+ }
+
+ return null;
+ }
+);
+QueueItemStatusLabel.displayName = 'QueueItemStatusLabel';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx
new file mode 100644
index 0000000000..86d14450d2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx
@@ -0,0 +1,22 @@
+import type { CanvasSessionContextValue } from 'features/controlLayers/components/SimpleSession/context';
+import {
+ buildProgressDataAtom,
+ CanvasSessionContextProvider,
+} from 'features/controlLayers/components/SimpleSession/context';
+import { StagingArea } from 'features/controlLayers/components/SimpleSession/StagingArea';
+import type { SimpleSessionIdentifier } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import { memo, useMemo } from 'react';
+
+export const SimpleSession = memo(({ session }: { session: SimpleSessionIdentifier }) => {
+ const ctx = useMemo(
+ () => ({ session, $progressData: buildProgressDataAtom() }) satisfies CanvasSessionContextValue,
+ [session]
+ );
+
+ return (
+
+
+
+ );
+});
+SimpleSession.displayName = 'SimpleSession';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx
new file mode 100644
index 0000000000..8eef21c9fd
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx
@@ -0,0 +1,130 @@
+/* eslint-disable i18next/no-literal-string */
+
+import { Divider, Flex, Text } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { EMPTY_ARRAY } from 'app/store/constants';
+import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
+import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
+import { StagingAreaContent } from 'features/controlLayers/components/SimpleSession/StagingAreaContent';
+import { StagingAreaHeader } from 'features/controlLayers/components/SimpleSession/StagingAreaHeader';
+import { useStagingAreaKeyboardNav } from 'features/controlLayers/components/SimpleSession/use-staging-keyboard-nav';
+import { memo, useCallback, useEffect, useMemo, useState } from 'react';
+import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue';
+import type { S } from 'services/api/types';
+import { $socket, setProgress } from 'services/events/stores';
+
+const LIST_ALL_OPTIONS = {
+ selectFromResult: ({ data }) => {
+ if (!data) {
+ return { items: EMPTY_ARRAY };
+ }
+ return { items: data.filter(({ status }) => status !== 'canceled') };
+ },
+} satisfies Parameters[1];
+
+export const StagingArea = memo(() => {
+ const ctx = useCanvasSessionContext();
+ const [selectedItemId, setSelectedItemId] = useState(null);
+ const [autoSwitch, setAutoSwitch] = useState(true);
+ const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS);
+ const selectedItem = useMemo(() => {
+ if (items.length === 0) {
+ return null;
+ }
+ if (selectedItemId === null) {
+ return null;
+ }
+ return items.find(({ item_id }) => item_id === selectedItemId) ?? null;
+ }, [items, selectedItemId]);
+ const selectedItemIndex = useMemo(() => {
+ if (items.length === 0) {
+ return null;
+ }
+ if (selectedItemId === null) {
+ return null;
+ }
+ return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null;
+ }, [items, selectedItemId]);
+
+ const onSelectItemId = useCallback((item_id: number | null) => {
+ setSelectedItemId(item_id);
+ if (item_id !== null) {
+ document.getElementById(getQueueItemElementId(item_id))?.scrollIntoView();
+ }
+ }, []);
+
+ useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId);
+
+ useEffect(() => {
+ if (items.length === 0) {
+ onSelectItemId(null);
+ return;
+ }
+ if (selectedItemId === null && items.length > 0) {
+ onSelectItemId(items[0]?.item_id ?? null);
+ return;
+ }
+ }, [items, onSelectItemId, selectedItem, selectedItemId]);
+
+ const socket = useStore($socket);
+ useEffect(() => {
+ if (!socket) {
+ return;
+ }
+
+ const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
+ if (data.destination !== ctx.session.id) {
+ return;
+ }
+ if (data.status === 'in_progress' && autoSwitch) {
+ onSelectItemId(data.item_id);
+ }
+ };
+
+ socket.on('queue_item_status_changed', onQueueItemStatusChanged);
+
+ return () => {
+ socket.off('queue_item_status_changed', onQueueItemStatusChanged);
+ };
+ }, [autoSwitch, ctx.$progressData, ctx.session.id, onSelectItemId, socket]);
+
+ useEffect(() => {
+ if (!socket) {
+ return;
+ }
+ const onProgress = (data: S['InvocationProgressEvent']) => {
+ if (data.destination !== ctx.session.id) {
+ return;
+ }
+ setProgress(ctx.$progressData, data);
+ };
+ socket.on('invocation_progress', onProgress);
+
+ return () => {
+ socket.off('invocation_progress', onProgress);
+ };
+ }, [ctx.$progressData, ctx.session.id, socket]);
+
+ return (
+
+
+
+ {items.length > 0 && (
+
+ )}
+ {items.length === 0 && (
+
+ No generations
+
+ )}
+
+ );
+});
+StagingArea.displayName = 'StagingArea';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx
new file mode 100644
index 0000000000..ce1e29b3b2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx
@@ -0,0 +1,58 @@
+/* eslint-disable i18next/no-literal-string */
+import { Divider, Flex, Text } from '@invoke-ai/ui-library';
+import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
+import { QueueItemPreviewFull } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewFull';
+import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini';
+import { memo } from 'react';
+import type { S } from 'services/api/types';
+
+export const StagingAreaContent = memo(
+ ({
+ items,
+ selectedItem,
+ selectedItemId,
+ selectedItemIndex,
+ onChangeAutoSwitch,
+ onSelectItemId,
+ }: {
+ items: S['SessionQueueItem'][];
+ selectedItem: S['SessionQueueItem'] | null;
+ selectedItemId: number | null;
+ selectedItemIndex: number | null;
+ onChangeAutoSwitch: (autoSwitch: boolean) => void;
+ onSelectItemId: (itemId: number) => void;
+ }) => {
+ return (
+ <>
+
+ {selectedItem && selectedItemIndex !== null && (
+
+ )}
+ {!selectedItem && No generation selected}
+
+
+
+
+
+ {items.map((item, i) => (
+
+ ))}
+
+
+
+ >
+ );
+ }
+);
+StagingAreaContent.displayName = 'StagingAreaContent';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx
new file mode 100644
index 0000000000..f9e04c1ef4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx
@@ -0,0 +1,40 @@
+/* eslint-disable i18next/no-literal-string */
+import { Button, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import type { ChangeEvent } from 'react';
+import { memo, useCallback } from 'react';
+
+export const StagingAreaHeader = memo(
+ ({ autoSwitch, setAutoSwitch }: { autoSwitch: boolean; setAutoSwitch: (autoSwitch: boolean) => void }) => {
+ const dispatch = useAppDispatch();
+
+ const startOver = useCallback(() => {
+ dispatch(canvasSessionStarted({ sessionType: 'simple' }));
+ }, [dispatch]);
+
+ const onChangeAutoSwitch = useCallback(
+ (e: ChangeEvent) => {
+ setAutoSwitch(e.target.checked);
+ },
+ [setAutoSwitch]
+ );
+
+ return (
+
+
+ Generations
+
+
+
+ Auto-switch
+
+
+
+
+ );
+ }
+);
+StagingAreaHeader.displayName = 'StagingAreaHeader';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx
new file mode 100644
index 0000000000..6cbc10d334
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx
@@ -0,0 +1,135 @@
+import type {
+ AdvancedSessionIdentifier,
+ SimpleSessionIdentifier,
+} from 'features/controlLayers/store/canvasStagingAreaSlice';
+import type { ProgressImage } from 'features/nodes/types/common';
+import { atom, type WritableAtom } from 'nanostores';
+import type { PropsWithChildren } from 'react';
+import { createContext, memo, useContext, useEffect, useState } from 'react';
+import type { S } from 'services/api/types';
+import { assert } from 'tsafe';
+
+export type ProgressData = {
+ sessionId: string;
+ progressEvent: S['InvocationProgressEvent'] | null;
+ progressImage: ProgressImage | null;
+};
+
+export const buildProgressDataAtom = () => atom>({});
+
+export const useProgressData = (
+ $progressData: WritableAtom>,
+ sessionId: string
+): ProgressData => {
+ const [value, setValue] = useState(() => {
+ return $progressData.get()[sessionId] ?? { sessionId, progressEvent: null, progressImage: null };
+ });
+ useEffect(() => {
+ const unsub = $progressData.subscribe((data) => {
+ const progressData = data[sessionId];
+ if (!progressData) {
+ return;
+ }
+ setValue(progressData);
+ });
+ return () => {
+ unsub();
+ };
+ }, [$progressData, sessionId]);
+
+ return value;
+};
+
+export const useHasProgressImage = (
+ $progressData: WritableAtom>,
+ sessionId: string
+): boolean => {
+ const [value, setValue] = useState(false);
+ useEffect(() => {
+ const unsub = $progressData.subscribe((data) => {
+ const progressData = data[sessionId];
+ setValue(Boolean(progressData?.progressImage));
+ });
+ return () => {
+ unsub();
+ };
+ }, [$progressData, sessionId]);
+
+ return value;
+};
+
+export const setProgress = (
+ $progressData: WritableAtom>,
+ data: S['InvocationProgressEvent']
+) => {
+ const progressData = $progressData.get();
+ const current = progressData[data.session_id];
+ if (current) {
+ const next = { ...current };
+ next.progressEvent = data;
+ if (data.image) {
+ next.progressImage = data.image;
+ }
+ $progressData.set({
+ ...progressData,
+ [data.session_id]: next,
+ });
+ } else {
+ $progressData.set({
+ ...progressData,
+ [data.session_id]: {
+ sessionId: data.session_id,
+ progressEvent: data,
+ progressImage: data.image ?? null,
+ },
+ });
+ }
+};
+
+export const clearProgressEvent = ($progressData: WritableAtom>, sessionId: string) => {
+ const progressData = $progressData.get();
+ const current = progressData[sessionId];
+ if (!current) {
+ return;
+ }
+ const next = { ...current };
+ next.progressEvent = null;
+ $progressData.set({
+ ...progressData,
+ [sessionId]: next,
+ });
+};
+
+export const clearProgressImage = ($progressData: WritableAtom>, sessionId: string) => {
+ const progressData = $progressData.get();
+ const current = progressData[sessionId];
+ if (!current) {
+ return;
+ }
+ const next = { ...current };
+ next.progressImage = null;
+ $progressData.set({
+ ...progressData,
+ [sessionId]: next,
+ });
+};
+
+export type CanvasSessionContextValue = {
+ session: SimpleSessionIdentifier | AdvancedSessionIdentifier;
+ $progressData: WritableAtom>;
+};
+
+const CanvasSessionContext = createContext(null);
+
+export const CanvasSessionContextProvider = memo(
+ ({ value, children }: PropsWithChildren<{ value: CanvasSessionContextValue }>) => (
+ {children}
+ )
+);
+CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider';
+
+export const useCanvasSessionContext = () => {
+ const ctx = useContext(CanvasSessionContext);
+ assert(ctx !== null, "'useCanvasSessionContext' must be used within a CanvasSessionContextProvider");
+ return ctx;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts
new file mode 100644
index 0000000000..6736e2c306
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts
@@ -0,0 +1,51 @@
+import { skipToken } from '@reduxjs/toolkit/query';
+import { isImageField } from 'features/nodes/types/common';
+import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils';
+import { round } from 'lodash-es';
+import { useMemo } from 'react';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import type { S } from 'services/api/types';
+import { objectEntries } from 'tsafe';
+
+export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) => {
+ if (!data) {
+ return 'Generating';
+ }
+
+ let message = data.message;
+ if (data.percentage) {
+ message += ` (${round(data.percentage * 100)}%)`;
+ }
+ return message;
+};
+
+export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
+
+export const getQueueItemElementId = (item_id: number) => `queue-item-status-card-${item_id}`;
+
+const getOutputImageName = (item: S['SessionQueueItem']) => {
+ const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>
+ isCanvasOutputNodeId(nodeId)
+ )?.[1][0];
+ const output = nodeId ? item.session.results[nodeId] : undefined;
+
+ if (!output) {
+ return null;
+ }
+
+ for (const [_name, value] of objectEntries(output)) {
+ if (isImageField(value)) {
+ return value.image_name;
+ }
+ }
+
+ return null;
+};
+
+export const useOutputImageDTO = (item: S['SessionQueueItem']) => {
+ const outputImageName = useMemo(() => getOutputImageName(item), [item]);
+
+ const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken);
+
+ return imageDTO;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts
new file mode 100644
index 0000000000..9e2ac2f24c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts
@@ -0,0 +1,54 @@
+import { useCallback } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+import type { S } from 'services/api/types';
+
+export const useStagingAreaKeyboardNav = (
+ items: S['SessionQueueItem'][],
+ selectedItemId: number | null,
+ onSelectItemId: (item_id: number) => void
+) => {
+ const onNext = useCallback(() => {
+ if (selectedItemId === null) {
+ return;
+ }
+ const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
+ const nextIndex = (currentIndex + 1) % items.length;
+ const nextItem = items[nextIndex];
+ if (!nextItem) {
+ return;
+ }
+ onSelectItemId(nextItem.item_id);
+ }, [items, onSelectItemId, selectedItemId]);
+ const onPrev = useCallback(() => {
+ if (selectedItemId === null) {
+ return;
+ }
+ const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
+ const prevIndex = (currentIndex - 1 + items.length) % items.length;
+ const prevItem = items[prevIndex];
+ if (!prevItem) {
+ return;
+ }
+ onSelectItemId(prevItem.item_id);
+ }, [items, onSelectItemId, selectedItemId]);
+
+ const onFirst = useCallback(() => {
+ const first = items.at(0);
+ if (!first) {
+ return;
+ }
+ onSelectItemId(first.item_id);
+ }, [items, onSelectItemId]);
+ const onLast = useCallback(() => {
+ const last = items.at(-1);
+ if (!last) {
+ return;
+ }
+ onSelectItemId(last.item_id);
+ }, [items, onSelectItemId]);
+
+ useHotkeys('left', onPrev, { preventDefault: true });
+ useHotkeys('right', onNext, { preventDefault: true });
+ useHotkeys('meta+left', onFirst, { preventDefault: true });
+ useHotkeys('meta+right', onLast, { preventDefault: true });
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts
index bf035be31c..4a403b6baf 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts
@@ -6,8 +6,18 @@ import { canvasReset } from 'features/controlLayers/store/actions';
import type { StagingAreaImage, StagingAreaProgressImage } from 'features/controlLayers/store/types';
import { selectCanvasQueueCounts } from 'services/api/endpoints/queue';
+export type SimpleSessionIdentifier = {
+ type: 'simple';
+ id: string;
+};
+
+export type AdvancedSessionIdentifier = {
+ type: 'advanced';
+ id: string;
+};
+
type CanvasStagingAreaState = {
- session: { type: 'simple'; id: string } | { type: 'advanced'; id: string } | null;
+ session: SimpleSessionIdentifier | AdvancedSessionIdentifier | null;
sessionType: 'simple' | 'advanced' | null;
images: (StagingAreaImage | StagingAreaProgressImage)[];
selectedImageIndex: number;