diff --git a/pages/_app.tsx b/pages/_app.tsx index 0e2446858..fda17e7d1 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -14,6 +14,7 @@ import '~/common/styles/NProgress.css'; import '~/common/styles/agi.effects.css'; import '~/common/styles/app.styles.css'; +import { OverlaysInsert } from '~/common/layout/overlays/OverlaysInsert'; import { ProviderBackendCapabilities } from '~/common/providers/ProviderBackendCapabilities'; import { ProviderBootstrapLogic } from '~/common/providers/ProviderBootstrapLogic'; import { ProviderSingleTab } from '~/common/providers/ProviderSingleTab'; @@ -44,6 +45,7 @@ const Big_AGI_App = ({ Component, emotionCache, pageProps }: MyAppProps) => { {getLayout()} + diff --git a/src/apps/chat/AppChat.tsx b/src/apps/chat/AppChat.tsx index 5465602ec..4dbb01904 100644 --- a/src/apps/chat/AppChat.tsx +++ b/src/apps/chat/AppChat.tsx @@ -31,6 +31,7 @@ import { themeBgAppChatComposer } from '~/common/app.theme'; import { useFolderStore } from '~/common/state/store-folders'; import { useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts'; import { useIsMobile } from '~/common/components/useMatchMedia'; +import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents'; import { useRouterQuery } from '~/common/app.routes'; import { useUXLabsStore } from '~/common/state/store-ux-labs'; @@ -76,12 +77,11 @@ const composerClosedSx: SxProps = { export function AppChat() { // state + const { showPromisedOverlay } = useOverlayComponents(); const [isComposerMulticast, setIsComposerMulticast] = React.useState(false); const [isMessageSelectionMode, setIsMessageSelectionMode] = React.useState(false); const [diagramConfig, setDiagramConfig] = React.useState(null); const [tradeConfig, setTradeConfig] = React.useState(null); - const [clearConversationId, setClearConversationId] = React.useState(null); - const [deleteConversationIds, setDeleteConversationIds] = React.useState(null); const [flattenConversationId, setFlattenConversationId] = React.useState(null); const showNextTitleChange = React.useRef(false); const composerTextAreaRef = React.useRef(null); @@ -353,18 +353,28 @@ export function AppChat() { const handleConversationFlatten = React.useCallback((conversationId: DConversationId) => setFlattenConversationId(conversationId), []); - const handleConfirmedClearConversation = React.useCallback(() => { - if (clearConversationId) { - ConversationsManager.getHandler(clearConversationId).historyClear(); - setClearConversationId(null); + const handleConversationReset = React.useCallback(async (conversationId: DConversationId) => { + if (await showPromisedOverlay('chat-reset-confirmation', { rejectWithValue: false }, ({ onResolve, onUserReject }) => + onResolve(true)} + confirmationText='Are you sure you want to discard all messages?' + positiveActionText='Clear conversation' + />, + )) { + ConversationsManager.getHandler(conversationId).historyClear(); } - }, [clearConversationId]); + }, [showPromisedOverlay]); - const handleConversationClear = React.useCallback((conversationId: DConversationId) => setClearConversationId(conversationId), []); + const handleDeleteConversations = React.useCallback(async (conversationIds: DConversationId[], bypassConfirmation: boolean) => { - const handleDeleteConversations = React.useCallback((conversationIds: DConversationId[], bypassConfirmation: boolean) => { - if (!bypassConfirmation) - return setDeleteConversationIds(conversationIds); + // show confirmation dialog + if (!bypassConfirmation && !await showPromisedOverlay('chat-delete-confirmation', { rejectWithValue: false }, ({ onResolve, onUserReject }) => + onResolve(true)} + confirmationText={`Are you absolutely sure you want to delete ${conversationIds.length === 1 ? 'this conversation' : 'these conversations'}? This action cannot be undone.`} + positiveActionText={conversationIds.length === 1 ? 'Delete conversation' : `Yes, delete all ${conversationIds.length} conversations`} + />, + )) return; // perform deletion, and return the next (or a new) conversation const nextConversationId = deleteConversations(conversationIds, /*focusedSystemPurposeId ??*/ undefined); @@ -372,15 +382,9 @@ export function AppChat() { // switch the focused pane to the new conversation - NOTE: this makes the assumption that deletion had impact on the focused pane handleOpenConversationInFocusedPane(nextConversationId); - setDeleteConversationIds(null); - // run GC for dblobs in this conversation void gcChatImageAssets(); // fire/forget - }, [deleteConversations, handleOpenConversationInFocusedPane]); - - const handleConfirmedDeleteConversations = React.useCallback(() => { - !!deleteConversationIds?.length && handleDeleteConversations(deleteConversationIds, true); - }, [deleteConversationIds, handleDeleteConversations]); + }, [showPromisedOverlay, deleteConversations, handleOpenConversationInFocusedPane]); // Shortcuts @@ -398,13 +402,13 @@ export function AppChat() { { key: 'o', ctrl: true, action: handleFileOpenConversation }, { key: 's', ctrl: true, action: () => handleFileSaveConversation(focusedPaneConversationId) }, { key: 'n', ctrl: true, shift: true, action: handleConversationNewInFocusedPane }, - { key: 'x', ctrl: true, shift: true, action: () => isFocusedChatEmpty || (focusedPaneConversationId && handleConversationClear(focusedPaneConversationId)) }, + { key: 'x', ctrl: true, shift: true, action: () => isFocusedChatEmpty || (focusedPaneConversationId && handleConversationReset(focusedPaneConversationId)) }, { key: 'd', ctrl: true, shift: true, action: () => focusedPaneConversationId && handleDeleteConversations([focusedPaneConversationId], false) }, { key: '[', ctrl: true, action: () => handleNavigateHistoryInFocusedPane('back') }, { key: ']', ctrl: true, action: () => handleNavigateHistoryInFocusedPane('forward') }, // focused conversation llm { key: 'o', ctrl: true, shift: true, action: handleOpenChatLlmOptions }, - ], [focusedPaneConversationId, handleConversationClear, handleConversationNewInFocusedPane, handleDeleteConversations, handleFileOpenConversation, handleFileSaveConversation, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty])); + ], [focusedPaneConversationId, handleConversationReset, handleConversationNewInFocusedPane, handleDeleteConversations, handleFileOpenConversation, handleFileSaveConversation, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty])); // Pluggable Optima components @@ -445,12 +449,12 @@ export function AppChat() { hasConversations={hasConversations} isMessageSelectionMode={isMessageSelectionMode} onConversationBranch={handleConversationBranch} - onConversationClear={handleConversationClear} + onConversationClear={handleConversationReset} onConversationFlatten={handleConversationFlatten} // onConversationNew={handleConversationNewInFocusedPane} setIsMessageSelectionMode={setIsMessageSelectionMode} />, - [focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationFlatten, hasConversations, isFocusedChatEmpty, isMessageSelectionMode, isMobile], + [focusedPaneConversationId, handleConversationBranch, handleConversationReset, handleConversationFlatten, hasConversations, isFocusedChatEmpty, isMessageSelectionMode, isMobile], ); useSetOptimaAppMenu(focusedMenuItems, 'AppChat'); @@ -606,23 +610,5 @@ export function AppChat() { /> )} - {/* [confirmation] Reset Conversation */} - {!!clearConversationId && ( - setClearConversationId(null)} onPositive={handleConfirmedClearConversation} - confirmationText='Are you sure you want to discard all messages?' - positiveActionText='Clear conversation' - /> - )} - - {/* [confirmation] Delete All */} - {!!deleteConversationIds?.length && ( - setDeleteConversationIds(null)} onPositive={handleConfirmedDeleteConversations} - confirmationText={`Are you absolutely sure you want to delete ${deleteConversationIds.length === 1 ? 'this conversation' : 'these conversations'}? This action cannot be undone.`} - positiveActionText={deleteConversationIds.length === 1 ? 'Delete conversation' : `Yes, delete all ${deleteConversationIds.length} conversations`} - /> - )} - ; } diff --git a/src/common/layout/overlays/OverlaysInsert.tsx b/src/common/layout/overlays/OverlaysInsert.tsx new file mode 100644 index 000000000..8e3d682ac --- /dev/null +++ b/src/common/layout/overlays/OverlaysInsert.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import { useOverlayStore } from './store-overlays'; + + +export const OverlaysInsert: React.FC = () => { + + // external state + const overlays = useOverlayStore(state => state.overlays); + + // Transient Overlays / Modals + return overlays.map(({ id, component }) => ( + {component} + )); +}; diff --git a/src/common/layout/overlays/store-overlays.ts b/src/common/layout/overlays/store-overlays.ts new file mode 100644 index 000000000..ee8980062 --- /dev/null +++ b/src/common/layout/overlays/store-overlays.ts @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { create } from 'zustand'; + + +// configuration +const STRICT_OVERLAY_CHECKS = process.env.NODE_ENV === 'development'; + + +interface OverlayState { + overlays: OverlayItem[]; +} + +interface OverlayItem { + id: GlobalOverlayId; + component: React.ReactNode; + // rejectFn: (reason: any) => void; +} + +export type GlobalOverlayId = // string - disabled so we keep an orderliness + | 'chat-delete-confirmation' + | 'chat-reset-confirmation'; + +interface OverlayActions { + overlayExists: (id: GlobalOverlayId) => boolean; + appendOverlay: (id: GlobalOverlayId, component: React.ReactNode) => void; + removeOverlay: (id: GlobalOverlayId) => void; + overlayToFront: (id: GlobalOverlayId) => void; + // removeOverlaysBy: (predicate: (item: OverlayItem) => boolean) => void; +} + +export const useOverlayStore = create((set, get) => ({ + + // state + overlays: [], + + // actions + + overlayExists: (id) => get().overlays.some(o => o.id === id), + + appendOverlay: (id, component) => + set(state => { + + // sanity check: don't allow duplicate IDs + if (state.overlayExists(id)) { + if (STRICT_OVERLAY_CHECKS) + throw new Error(`appendOverlay: Overlay ID "${id}" already exists`); + else + console.warn(`Overlay ID "${id}" already exists`); + } + + return { + overlays: [ + ...state.overlays, + { id, component }, + ], + }; + }), + + /** + * This MUST only be called in the context of the calling hook, not by other parties, as it would leave + * the promises hangind. + * In this regard, these functions are just dumb for component insertion/removal. + */ + removeOverlay: (id) => + set(state => { + // sanity check: don't allow removal of non-existent overlays + if (!state.overlayExists(id)) { + if (STRICT_OVERLAY_CHECKS) + throw new Error(`removeOverlay: Overlay ID "${id}" does not exist`); + else + console.warn(`Overlay ID "${id}" does not exist`); + } + + // if (overlay && reason) + // overlay.rejectFn(reason); + return { + overlays: state.overlays.filter(o => o.id !== id), + }; + }), + + /** + * Bring the overlay to the front, which means to move it to the end of the list. + * @param id + */ + overlayToFront: (id) => + set(state => { + if (!state.overlayExists(id)) { + if (STRICT_OVERLAY_CHECKS) + throw new Error(`overlayToFront: Overlay ID "${id}" does not exist`); + else + console.warn(`Overlay ID "${id}" does not exist`); + return state; // Return the current state without changes + } + + const overlay = state.overlays.find(o => o.id === id); + if (!overlay) return state; // This shouldn't happen due to the check above, but TypeScript doesn't know that + console.log('reordering'); + return { + overlays: [ + ...state.overlays.filter(o => o !== overlay), + overlay, + ], + }; + }), + + // removeOverlaysBy: (predicate) => + // set(state => { + // // const overlaysToRemove = state.overlays.filter(predicate); + // // overlaysToRemove.forEach(overlay => { + // // if (reason) + // // overlay.rejectFn(reason); + // // }); + // return { + // overlays: state.overlays.filter(o => !predicate(o)), + // }; + // }), + +})); diff --git a/src/common/layout/overlays/useOverlayComponents.tsx b/src/common/layout/overlays/useOverlayComponents.tsx new file mode 100644 index 000000000..e93cb6c34 --- /dev/null +++ b/src/common/layout/overlays/useOverlayComponents.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; + +import { GlobalOverlayId, useOverlayStore } from './store-overlays'; + + +enum OverlayCloseReason { + USER_REJECTED = 'USER_REJECTED', + UNMOUNTED = 'UNMOUNTED', + ALREADY_SHOWN = 'ALREADY_SHOWN', +} + +type OverlayComponentProps = { + onResolve: (value: TResolve) => void; + onUserReject: () => void; +}; + +interface ShowOverlayOptions { + doNotRejectOnUnmount?: boolean; + rejectWithValue?: Exclude; // saves a try/catch in the caller +} + +/** + * Show overlays with promise-like callbacks. IDs are global and unique, for ease of deduplication. + * - When the component unmounts, by default it will reject all the overlays that don't have + * the `doNotRejectOnUnmount` option set. + * - When a new overlay is requested, it will check if it's already open and reject it if so, + * and bring it to the front. + */ +export function useOverlayComponents() { + + // keep track of active overlays + // NOTE: this keeps track of the IDs that are open where this hook is used, while the store keeps track of all the components + // NOTE2: + const activeOverlaysRef = React.useRef<{ + id: GlobalOverlayId; + doReject: (reason: OverlayCloseReason) => void; + }[]>([]); + + // on unmount, reject all active overlays + React.useEffect(() => { + return () => { + for (const { doReject } of activeOverlaysRef.current) + doReject(OverlayCloseReason.UNMOUNTED); + activeOverlaysRef.current = []; + }; + }, []); + + // create a new overlay component with promise-like callbacks + const showPromisedOverlay = React.useCallback(( + overlayId: GlobalOverlayId, + options: ShowOverlayOptions = {}, + Component: React.ComponentType>, + ): Promise => { + return new Promise((pResolve, pReject) => { + + const { appendOverlay, overlayExists, overlayToFront, removeOverlay } = useOverlayStore.getState(); + + // Check if the overlay already exists and exit early + // This is like doReject, but doesn't remove the overlay as we don't insert it + if (overlayExists(overlayId)) { + console.log(`Note: requesting dialog '${overlayId}' while still open.`); + if (options.rejectWithValue !== undefined) + pResolve(options.rejectWithValue); + else + pReject(OverlayCloseReason.ALREADY_SHOWN); + overlayToFront(overlayId); + return; + } + + const _doRemove = (): boolean => { + if (!overlayExists(overlayId)) + return false; + removeOverlay(overlayId); + activeOverlaysRef.current = activeOverlaysRef.current.filter(o => o.id !== overlayId); + return true; + }; + + const doResolve = (value: TResolve) => { + if (_doRemove()) + pResolve(value); + }; + + const doReject = (reason: OverlayCloseReason) => { + if (_doRemove()) { + if (options.rejectWithValue !== undefined) + pResolve(options.rejectWithValue); + else + pReject(reason); + } + }; + + appendOverlay(overlayId, + { + doReject(OverlayCloseReason.USER_REJECTED); + }} + />, + ); + + if (!options.doNotRejectOnUnmount) + activeOverlaysRef.current.push({ id: overlayId, doReject }); + }); + }, []); + + return { showPromisedOverlay }; +}