feat(ui): rework queue controls

- Remove queue front button. Hold shift while clicking `Invoke` button to queue front.
- Restore queue menu actions w/ the reclaimed space.
- Simplify queue interaction hooks.
This commit is contained in:
psychedelicious
2024-09-17 19:19:21 +10:00
committed by Kent Keirsey
parent 7b9d8df1a7
commit ccbe1b233d
10 changed files with 147 additions and 120 deletions

View File

@@ -22,7 +22,6 @@ export type AppFeature =
| 'multiselect'
| 'pauseQueue'
| 'resumeQueue'
| 'prependQueue'
| 'invocationCache'
| 'bulkDownload'
| 'starterModels'

View File

@@ -2,8 +2,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { addScope, removeScope, setScopes } from 'common/hooks/interactionScopes';
import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
import { useQueueFront } from 'features/queue/hooks/useQueueFront';
import { useInvoke } from 'features/queue/hooks/useInvoke';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -11,30 +10,28 @@ import { useHotkeys } from 'react-hotkeys-hook';
export const useGlobalHotkeys = () => {
const dispatch = useAppDispatch();
const isModelManagerEnabled = useFeatureStatus('modelManager');
const { queueBack, isDisabled: isDisabledQueueBack, isLoading: isLoadingQueueBack } = useQueueBack();
const queue = useInvoke();
useHotkeys(
['ctrl+enter', 'meta+enter'],
queueBack,
queue.queueBack,
{
enabled: !isDisabledQueueBack && !isLoadingQueueBack,
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
[queueBack, isDisabledQueueBack, isLoadingQueueBack]
[queue]
);
const { queueFront, isDisabled: isDisabledQueueFront, isLoading: isLoadingQueueFront } = useQueueFront();
useHotkeys(
['ctrl+shift+enter', 'meta+shift+enter'],
queueFront,
queue.queueFront,
{
enabled: !isDisabledQueueFront && !isLoadingQueueFront,
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
[queueFront, isDisabledQueueFront, isLoadingQueueFront]
[queue]
);
const {

View File

@@ -1,14 +1,12 @@
import { IconButton, useShiftModifier } from '@invoke-ai/ui-library';
import { QueueCountBadge } from 'features/queue/components/QueueCountBadge';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { memo, useRef } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold, PiXBold } from 'react-icons/pi';
import { useClearQueue } from './ClearQueueConfirmationAlertDialog';
export const ClearQueueIconButton = memo((_) => {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const clearQueue = useClearQueue();
const cancelCurrentQueueItem = useCancelCurrentQueueItem();
@@ -18,22 +16,17 @@ export const ClearQueueIconButton = memo((_) => {
const shift = useShiftModifier();
return (
<>
<IconButton
ref={ref}
size="lg"
isDisabled={shift ? clearQueue.isDisabled : cancelCurrentQueueItem.isDisabled}
isLoading={shift ? clearQueue.isLoading : cancelCurrentQueueItem.isLoading}
aria-label={shift ? t('queue.clear') : t('queue.cancel')}
tooltip={shift ? t('queue.clearTooltip') : t('queue.cancelTooltip')}
icon={shift ? <PiTrashSimpleBold /> : <PiXBold />}
colorScheme="error"
onClick={shift ? clearQueue.openDialog : cancelCurrentQueueItem.cancelQueueItem}
data-testid={shift ? t('queue.clear') : t('queue.cancel')}
/>
{/* The badge is dynamically positioned, needs a ref to the target element */}
<QueueCountBadge targetRef={ref} />
</>
<IconButton
size="lg"
isDisabled={shift ? clearQueue.isDisabled : cancelCurrentQueueItem.isDisabled}
isLoading={shift ? clearQueue.isLoading : cancelCurrentQueueItem.isLoading}
aria-label={shift ? t('queue.clear') : t('queue.cancel')}
tooltip={shift ? t('queue.clearTooltip') : t('queue.cancelTooltip')}
icon={shift ? <PiTrashSimpleBold /> : <PiXBold />}
colorScheme="error"
onClick={shift ? clearQueue.openDialog : cancelCurrentQueueItem.cancelQueueItem}
data-testid={shift ? t('queue.clear') : t('queue.cancel')}
/>
);
});

View File

@@ -1,29 +1,30 @@
import { Button, Flex, Spacer } from '@invoke-ai/ui-library';
import { Button, Flex, Spacer, useShiftModifier } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { QueueIterationsNumberInput } from 'features/queue/components/QueueIterationsNumberInput';
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
import { useInvoke } from 'features/queue/hooks/useInvoke';
import { memo } from 'react';
import { PiSparkleFill } from 'react-icons/pi';
import { PiLightningFill, PiSparkleFill } from 'react-icons/pi';
import { QueueButtonTooltip } from './QueueButtonTooltip';
const invoke = 'Invoke';
export const InvokeQueueBackButton = memo(() => {
const { queueBack, isLoading, isDisabled } = useQueueBack();
export const InvokeButton = memo(() => {
const queue = useInvoke();
const shift = useShiftModifier();
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
return (
<Flex pos="relative" w="200px">
<QueueIterationsNumberInput />
<QueueButtonTooltip>
<QueueButtonTooltip prepend={shift}>
<Button
onClick={queueBack}
isLoading={isLoading || isLoadingDynamicPrompts}
onClick={shift ? queue.queueFront : queue.queueBack}
isLoading={queue.isLoading || isLoadingDynamicPrompts}
loadingText={invoke}
isDisabled={isDisabled}
rightIcon={<PiSparkleFill />}
isDisabled={queue.isDisabled}
rightIcon={shift ? <PiLightningFill /> : <PiSparkleFill />}
variant="solid"
colorScheme="invokeYellow"
size="lg"
@@ -40,4 +41,4 @@ export const InvokeQueueBackButton = memo(() => {
);
});
InvokeQueueBackButton.displayName = 'InvokeQueueBackButton';
InvokeButton.displayName = 'InvokeQueueBackButton';

View File

@@ -0,0 +1,80 @@
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { QueueCountBadge } from 'features/queue/components/QueueCountBadge';
import { usePauseProcessor } from 'features/queue/hooks/usePauseProcessor';
import { useResumeProcessor } from 'features/queue/hooks/useResumeProcessor';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiListBold, PiPauseFill, PiPlayFill, PiQueueBold, PiTrashSimpleBold } from 'react-icons/pi';
export const QueueActionsMenuButton = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
const clearQueue = useClearQueue();
const {
resumeProcessor,
isLoading: isLoadingResumeProcessor,
isDisabled: isDisabledResumeProcessor,
} = useResumeProcessor();
const {
pauseProcessor,
isLoading: isLoadingPauseProcessor,
isDisabled: isDisabledPauseProcessor,
} = usePauseProcessor();
const openQueue = useCallback(() => {
dispatch(setActiveTab('queue'));
}, [dispatch]);
return (
<>
<Menu placement="bottom-end">
<MenuButton ref={ref} as={IconButton} size="lg" aria-label="Queue Actions Menu" icon={<PiListBold />} />
<MenuList>
<MenuItem
isDestructive
icon={<PiTrashSimpleBold />}
onClick={clearQueue.openDialog}
isLoading={clearQueue.isLoading}
isDisabled={clearQueue.isDisabled}
>
{t('queue.clearTooltip')}
</MenuItem>
{isResumeEnabled && (
<MenuItem
icon={<PiPlayFill />}
onClick={resumeProcessor}
isLoading={isLoadingResumeProcessor}
isDisabled={isDisabledResumeProcessor}
>
{t('queue.resumeTooltip')}
</MenuItem>
)}
{isPauseEnabled && (
<MenuItem
icon={<PiPauseFill />}
onClick={pauseProcessor}
isLoading={isLoadingPauseProcessor}
isDisabled={isDisabledPauseProcessor}
>
{t('queue.pauseTooltip')}
</MenuItem>
)}
<MenuDivider />
<MenuItem icon={<PiQueueBold />} onClick={openQueue}>
{t('queue.openQueue')}
</MenuItem>
</MenuList>
</Menu>
{/* The badge is dynamically positioned, needs a ref to the target element */}
<QueueCountBadge targetRef={ref} />
</>
);
});
QueueActionsMenuButton.displayName = 'QueueActionsMenuButton';

View File

@@ -1,26 +1,24 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ClearQueueIconButton } from 'features/queue/components/ClearQueueIconButton';
import QueueFrontButton from 'features/queue/components/QueueFrontButton';
import { QueueActionsMenuButton } from 'features/queue/components/QueueActionsMenuButton';
import { SendToToggle } from 'features/queue/components/SendToToggle';
import ProgressBar from 'features/system/components/ProgressBar';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import { InvokeQueueBackButton } from './InvokeQueueBackButton';
import { InvokeButton } from './InvokeQueueBackButton';
const QueueControls = () => {
const isPrependEnabled = useFeatureStatus('prependQueue');
const tab = useAppSelector(selectActiveTab);
return (
<Flex w="full" position="relative" borderRadius="base" gap={2} flexDir="column">
<Flex gap={2}>
{isPrependEnabled && <QueueFrontButton />}
<InvokeQueueBackButton />
<InvokeButton />
<Spacer />
{tab === 'canvas' && <SendToToggle />}
<QueueActionsMenuButton />
<ClearQueueIconButton />
</Flex>
<ProgressBar />

View File

@@ -1,26 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useQueueFront } from 'features/queue/hooks/useQueueFront';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiLightningFill } from 'react-icons/pi';
import { QueueButtonTooltip } from './QueueButtonTooltip';
const QueueFrontButton = () => {
const { t } = useTranslation();
const { queueFront, isLoading, isDisabled } = useQueueFront();
return (
<QueueButtonTooltip prepend>
<IconButton
aria-label={t('queue.queueFront')}
isDisabled={isDisabled}
isLoading={isLoading}
onClick={queueFront}
icon={<PiLightningFill />}
size="lg"
/>
</QueueButtonTooltip>
);
};
export default memo(QueueFrontButton);

View File

@@ -2,23 +2,28 @@ import { enqueueRequested } from 'app/store/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsReadyToEnqueue } from 'common/hooks/useIsReadyToEnqueue';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { useEnqueueBatchMutation } from 'services/api/endpoints/queue';
export const useQueueBack = () => {
export const useInvoke = () => {
const dispatch = useAppDispatch();
const tabName = useAppSelector(selectActiveTab);
const { isReady } = useIsReadyToEnqueue();
const [_, { isLoading }] = useEnqueueBatchMutation({
fixedCacheKey: 'enqueueBatch',
});
const isDisabled = useMemo(() => !isReady, [isReady]);
const queueBack = useCallback(() => {
if (isDisabled) {
if (!isReady) {
return;
}
dispatch(enqueueRequested({ tabName, prepend: false }));
}, [dispatch, isDisabled, tabName]);
}, [dispatch, isReady, tabName]);
const queueFront = useCallback(() => {
if (!isReady) {
return;
}
dispatch(enqueueRequested({ tabName, prepend: true }));
}, [dispatch, isReady, tabName]);
return { queueBack, isLoading, isDisabled };
return { queueBack, queueFront, isLoading, isDisabled: !isReady };
};

View File

@@ -1,30 +0,0 @@
import { enqueueRequested } from 'app/store/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsReadyToEnqueue } from 'common/hooks/useIsReadyToEnqueue';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
import { useEnqueueBatchMutation } from 'services/api/endpoints/queue';
export const useQueueFront = () => {
const dispatch = useAppDispatch();
const tabName = useAppSelector(selectActiveTab);
const { isReady } = useIsReadyToEnqueue();
const [_, { isLoading }] = useEnqueueBatchMutation({
fixedCacheKey: 'enqueueBatch',
});
const prependEnabled = useFeatureStatus('prependQueue');
const isDisabled = useMemo(() => {
return !isReady || !prependEnabled;
}, [isReady, prependEnabled]);
const queueFront = useCallback(() => {
if (isDisabled) {
return;
}
dispatch(enqueueRequested({ tabName, prepend: true }));
}, [dispatch, isDisabled, tabName]);
return { queueFront, isLoading, isDisabled };
};

View File

@@ -1,13 +1,19 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { ButtonGroup, Flex, Icon, IconButton, Portal, spinAnimation } from '@invoke-ai/ui-library';
import { ButtonGroup, Flex, Icon, IconButton, Portal, spinAnimation, useShiftModifier } from '@invoke-ai/ui-library';
import CancelCurrentQueueItemIconButton from 'features/queue/components/CancelCurrentQueueItemIconButton';
import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { QueueButtonTooltip } from 'features/queue/components/QueueButtonTooltip';
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
import { useInvoke } from 'features/queue/hooks/useInvoke';
import type { UsePanelReturn } from 'features/ui/hooks/usePanel';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCircleNotchBold, PiSlidersHorizontalBold, PiSparkleFill, PiTrashSimpleBold } from 'react-icons/pi';
import {
PiCircleNotchBold,
PiLightningFill,
PiSlidersHorizontalBold,
PiSparkleFill,
PiTrashSimpleBold,
} from 'react-icons/pi';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
const floatingButtonStyles: SystemStyleObject = {
@@ -21,17 +27,21 @@ type Props = {
const FloatingSidePanelButtons = (props: Props) => {
const { t } = useTranslation();
const { queueBack, isLoading, isDisabled } = useQueueBack();
const queue = useInvoke();
const shift = useShiftModifier();
const clearQueue = useClearQueue();
const { data: queueStatus } = useGetQueueStatusQuery();
const queueButtonIcon = useMemo(() => {
const isProcessing = (queueStatus?.queue.in_progress ?? 0) > 0;
if (!isDisabled && isProcessing) {
if (!queue.isDisabled && isProcessing) {
return <Icon boxSize={6} as={PiCircleNotchBold} animation={spinAnimation} />;
}
if (shift) {
return <PiLightningFill size="16px" />;
}
return <PiSparkleFill size="16px" />;
}, [isDisabled, queueStatus]);
}, [queue.isDisabled, queueStatus?.queue.in_progress, shift]);
if (!props.panelApi.isCollapsed) {
return null;
@@ -58,12 +68,12 @@ const FloatingSidePanelButtons = (props: Props) => {
sx={floatingButtonStyles}
icon={<PiSlidersHorizontalBold size="16px" />}
/>
<QueueButtonTooltip>
<QueueButtonTooltip prepend={shift}>
<IconButton
aria-label={t('queue.queueBack')}
onClick={queueBack}
isLoading={isLoading}
isDisabled={isDisabled}
onClick={shift ? queue.queueFront : queue.queueBack}
isLoading={queue.isLoading}
isDisabled={queue.isDisabled}
icon={queueButtonIcon}
colorScheme="invokeYellow"
sx={floatingButtonStyles}