mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Overlays: overlay framework
This commit is contained in:
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user