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 };
+}