Overlays: overlay framework

This commit is contained in:
Enrico Ros
2024-08-06 18:36:22 -07:00
parent 00c2186106
commit bd808594cb
5 changed files with 268 additions and 40 deletions
+2
View File
@@ -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) => {
<ProviderBootstrapLogic>
<SnackbarInsert />
{getLayout(<Component {...pageProps} />)}
<OverlaysInsert />
</ProviderBootstrapLogic>
</ProviderBackendCapabilities>
</ProviderSingleTab>
+26 -40
View File
@@ -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<DiagramConfig | null>(null);
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(null);
const [clearConversationId, setClearConversationId] = React.useState<DConversationId | null>(null);
const [deleteConversationIds, setDeleteConversationIds] = React.useState<DConversationId[] | null>(null);
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
const showNextTitleChange = React.useRef(false);
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(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 }) =>
<ConfirmationModal
open onClose={onUserReject} onPositive={() => 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 }) =>
<ConfirmationModal
open onClose={onUserReject} onPositive={() => 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 && (
<ConfirmationModal
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText='Are you sure you want to discard all messages?'
positiveActionText='Clear conversation'
/>
)}
{/* [confirmation] Delete All */}
{!!deleteConversationIds?.length && (
<ConfirmationModal
open onClose={() => 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`}
/>
)}
</>;
}
@@ -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 }) => (
<React.Fragment key={id}>{component}</React.Fragment>
));
};
@@ -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<OverlayState & OverlayActions>((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)),
// };
// }),
}));
@@ -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<TResolve> = {
onResolve: (value: TResolve) => void;
onUserReject: () => void;
};
interface ShowOverlayOptions<TResolve> {
doNotRejectOnUnmount?: boolean;
rejectWithValue?: Exclude<TResolve, undefined>; // 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(<TResolve, >(
overlayId: GlobalOverlayId,
options: ShowOverlayOptions<TResolve> = {},
Component: React.ComponentType<OverlayComponentProps<TResolve>>,
): Promise<TResolve> => {
return new Promise<TResolve>((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,
<Component
onResolve={doResolve}
onUserReject={() => {
doReject(OverlayCloseReason.USER_REJECTED);
}}
/>,
);
if (!options.doNotRejectOnUnmount)
activeOverlaysRef.current.push({ id: overlayId, doReject });
});
}, []);
return { showPromisedOverlay };
}