🔀 fix: Rerender Edge Cases After Migration to Shared Package (#8713)

* fix: render issues in PromptForm by decoupling nested dependencies as a result of @librechat/client components

* fix: MemoryViewer flicker by moving EditMemoryButton and DeleteMemoryButton outside of rendering

* fix: CategorySelector to use DropdownPopup for improved mobile compatibility

* chore: imports
This commit is contained in:
Danny Avila 2025-07-28 15:14:37 -04:00 committed by GitHub
parent 8e6eef04ab
commit a4ca4b7d9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 401 additions and 268 deletions

View file

@ -1,8 +1,10 @@
import React, { useCallback } from 'react';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { useDeletePrompt } from '~/data-provider';
import { Button, OGDialog, OGDialogTrigger, Label, OGDialogTemplate } from '@librechat/client'; import { Button, OGDialog, OGDialogTrigger, Label, OGDialogTemplate } from '@librechat/client';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
const DeleteVersion = ({ const DeleteConfirmDialog = ({
name, name,
disabled, disabled,
selectHandler, selectHandler,
@ -58,4 +60,42 @@ const DeleteVersion = ({
); );
}; };
export default DeleteVersion; interface DeletePromptProps {
promptId?: string;
groupId: string;
promptName: string;
disabled: boolean;
}
const DeletePrompt = React.memo(
({ promptId, groupId, promptName, disabled }: DeletePromptProps) => {
const deletePromptMutation = useDeletePrompt();
const handleDelete = useCallback(() => {
if (!promptId) {
console.warn('No prompt ID provided for deletion');
return;
}
deletePromptMutation.mutate({
_id: promptId,
groupId,
});
}, [promptId, groupId, deletePromptMutation]);
if (!promptId) {
return null;
}
return (
<DeleteConfirmDialog
name={promptName}
disabled={disabled || !promptId}
selectHandler={handleDelete}
/>
);
},
);
DeletePrompt.displayName = 'DeletePrompt';
export default DeletePrompt;

View file

@ -1,10 +1,13 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { Dropdown } from '@librechat/client'; import * as Ariakit from '@ariakit/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFormContext, Controller } from 'react-hook-form'; import { DropdownPopup } from '@librechat/client';
import { LocalStorageKeys } from 'librechat-data-provider'; import { LocalStorageKeys } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { MenuItemProps } from '@librechat/client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCategories } from '~/hooks'; import { useCategories } from '~/hooks';
import { cn } from '~/utils';
interface CategorySelectorProps { interface CategorySelectorProps {
currentCategory?: string; currentCategory?: string;
@ -20,10 +23,11 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const formContext = useFormContext(); const formContext = useFormContext();
const { categories, emptyCategory } = useCategories(); const { categories, emptyCategory } = useCategories();
const [isOpen, setIsOpen] = useState(false);
const control = formContext.control; const control = formContext?.control;
const watch = formContext.watch; const watch = formContext?.watch;
const setValue = formContext.setValue; const setValue = formContext?.setValue;
const watchedCategory = watch ? watch('category') : currentCategory; const watchedCategory = watch ? watch('category') : currentCategory;
@ -46,53 +50,71 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
return categoryOption; return categoryOption;
}, [categoryOption, t]); }, [categoryOption, t]);
const menuItems: MenuItemProps[] = useMemo(() => {
if (!categories) return [];
return categories.map((category) => ({
id: category.value,
label: category.label,
icon: 'icon' in category ? category.icon : undefined,
onClick: () => {
const value = category.value || '';
if (formContext && setValue) {
setValue('category', value, { shouldDirty: false });
}
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
onValueChange?.(value);
setIsOpen(false);
},
}));
}, [categories, formContext, setValue, onValueChange]);
const trigger = (
<Ariakit.MenuButton
className={cn(
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
'w-fit gap-2',
className,
)}
onClick={() => setIsOpen(!isOpen)}
aria-label="Prompt's category selector"
aria-labelledby="category-selector-label"
>
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.value ? displayCategory.label : t('com_ui_category')}</span>
</div>
<Ariakit.MenuButtonArrow />
</Ariakit.MenuButton>
);
return formContext ? ( return formContext ? (
<Controller <Controller
name="category" name="category"
control={control} control={control}
render={() => ( render={() => (
<Dropdown <DropdownPopup
value={displayCategory.value ?? ''} trigger={trigger}
label={displayCategory.value ? undefined : t('com_ui_category')} items={menuItems}
onChange={(value: string) => { isOpen={isOpen}
setValue('category', value, { shouldDirty: false }); setIsOpen={setIsOpen}
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value); menuId="category-selector-menu"
onValueChange?.(value); className="mt-2"
}} portal={true}
aria-labelledby="category-selector-label"
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={() => (
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/> />
)} )}
/> />
) : ( ) : (
<Dropdown <DropdownPopup
value={currentCategory ?? ''} trigger={trigger}
onChange={(value: string) => { items={menuItems}
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value); isOpen={isOpen}
onValueChange?.(value); setIsOpen={setIsOpen}
}} menuId="category-selector-menu"
aria-labelledby="category-selector-label" className="mt-2"
ariaLabel="Prompt's category selector" portal={true}
className={className}
options={categories || []}
renderValue={() => (
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/> />
); );
}; };

View file

@ -1,4 +1,5 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import React from 'react';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Menu, Rocket } from 'lucide-react'; import { Menu, Rocket } from 'lucide-react';
@ -6,14 +7,13 @@ import { useForm, FormProvider } from 'react-hook-form';
import { useParams, useOutletContext } from 'react-router-dom'; import { useParams, useOutletContext } from 'react-router-dom';
import { Button, Skeleton, useToastContext } from '@librechat/client'; import { Button, Skeleton, useToastContext } from '@librechat/client';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider'; import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TCreatePrompt } from 'librechat-data-provider'; import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
import { import {
useCreatePrompt,
useGetPrompts, useGetPrompts,
useCreatePrompt,
useGetPromptGroup, useGetPromptGroup,
useUpdatePromptGroup, useUpdatePromptGroup,
useMakePromptProduction, useMakePromptProduction,
useDeletePrompt,
} from '~/data-provider'; } from '~/data-provider';
import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks'; import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
import CategorySelector from './Groups/CategorySelector'; import CategorySelector from './Groups/CategorySelector';
@ -22,7 +22,7 @@ import PromptVariables from './PromptVariables';
import { cn, findPromptGroup } from '~/utils'; import { cn, findPromptGroup } from '~/utils';
import PromptVersions from './PromptVersions'; import PromptVersions from './PromptVersions';
import { PromptsEditorMode } from '~/common'; import { PromptsEditorMode } from '~/common';
import DeleteConfirm from './DeleteVersion'; import DeleteVersion from './DeleteVersion';
import PromptDetails from './PromptDetails'; import PromptDetails from './PromptDetails';
import PromptEditor from './PromptEditor'; import PromptEditor from './PromptEditor';
import SkeletonForm from './SkeletonForm'; import SkeletonForm from './SkeletonForm';
@ -32,16 +32,136 @@ import PromptName from './PromptName';
import Command from './Command'; import Command from './Command';
import store from '~/store'; import store from '~/store';
interface RightPanelProps {
group: TPromptGroup;
prompts: TPrompt[];
selectedPrompt: any;
selectionIndex: number;
selectedPromptId?: string;
isLoadingPrompts: boolean;
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
}
const RightPanel = React.memo(
({
group,
prompts,
selectedPrompt,
selectedPromptId,
isLoadingPrompts,
selectionIndex,
setSelectionIndex,
}: RightPanelProps) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const editorMode = useRecoilValue(store.promptsEditorMode);
const hasShareAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.SHARED_GLOBAL,
});
const updateGroupMutation = useUpdatePromptGroup({
onError: () => {
showToast({
status: 'error',
message: localize('com_ui_prompt_update_error'),
});
},
});
const makeProductionMutation = useMakePromptProduction();
const groupId = group?._id || '';
const groupName = group?.name || '';
const groupCategory = group?.category || '';
const isLoadingGroup = !group;
return (
<div
className="h-full w-full overflow-y-auto bg-surface-primary px-4"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
<CategorySelector
currentCategory={groupCategory}
onValueChange={(value) =>
updateGroupMutation.mutate({
id: groupId,
payload: { name: groupName, category: value },
})
}
/>
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && (
<Button
variant="submit"
size="sm"
aria-label="Make prompt production"
className="h-10 w-10 border border-transparent p-0.5 transition-all"
onClick={() => {
if (!selectedPrompt) {
console.warn('No prompt is selected');
return;
}
const { _id: promptVersionId = '', prompt } = selectedPrompt;
makeProductionMutation.mutate({
id: promptVersionId,
groupId,
productionPrompt: { prompt },
});
}}
disabled={
isLoadingGroup ||
!selectedPrompt ||
selectedPrompt._id === group?.productionId ||
makeProductionMutation.isLoading
}
>
<Rocket className="size-5 cursor-pointer text-white" />
</Button>
)}
<DeleteVersion
promptId={selectedPromptId}
groupId={groupId}
promptName={groupName}
disabled={isLoadingGroup}
/>
</div>
</div>
{editorMode === PromptsEditorMode.ADVANCED &&
(isLoadingPrompts
? Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
<Skeleton className="h-[72px] w-full" />
</div>
))
: prompts.length > 0 && (
<PromptVersions
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
setSelectionIndex={setSelectionIndex}
/>
))}
</div>
);
},
);
RightPanel.displayName = 'RightPanel';
const PromptForm = () => { const PromptForm = () => {
const params = useParams(); const params = useParams();
const localize = useLocalize(); const localize = useLocalize();
const { user } = useAuthContext(); const { user } = useAuthContext();
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const promptId = params.promptId || ''; const promptId = params.promptId || '';
const [selectionIndex, setSelectionIndex] = useState<number>(0);
const editorMode = useRecoilValue(store.promptsEditorMode); const editorMode = useRecoilValue(store.promptsEditorMode);
const [selectionIndex, setSelectionIndex] = useState<number>(0);
const prevIsEditingRef = useRef(false); const prevIsEditingRef = useRef(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [initialLoad, setInitialLoad] = useState(true); const [initialLoad, setInitialLoad] = useState(true);
@ -72,11 +192,9 @@ const PromptForm = () => {
[prompts, selectionIndex], [prompts, selectionIndex],
); );
const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
const { groupsQuery } = useOutletContext<ReturnType<typeof usePromptGroupsNav>>(); const { groupsQuery } = useOutletContext<ReturnType<typeof usePromptGroupsNav>>();
const hasShareAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.SHARED_GLOBAL,
});
const updateGroupMutation = useUpdatePromptGroup({ const updateGroupMutation = useUpdatePromptGroup({
onError: () => { onError: () => {
@ -88,7 +206,6 @@ const PromptForm = () => {
}); });
const makeProductionMutation = useMakePromptProduction(); const makeProductionMutation = useMakePromptProduction();
const deletePromptMutation = useDeletePrompt();
const createPromptMutation = useCreatePrompt({ const createPromptMutation = useCreatePrompt({
onMutate: (variables) => { onMutate: (variables) => {
@ -177,24 +294,40 @@ const PromptForm = () => {
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
const debouncedUpdateOneliner = useCallback( const debouncedUpdateOneliner = useMemo(
debounce((oneliner: string) => { () =>
if (!group || !group._id) { debounce((groupId: string, oneliner: string, mutate: any) => {
return console.warn('Group not found'); mutate({ id: groupId, payload: { oneliner } });
} }, 950),
updateGroupMutation.mutate({ id: group._id, payload: { oneliner } }); [],
}, 950),
[updateGroupMutation, group],
); );
const debouncedUpdateCommand = useCallback( const debouncedUpdateCommand = useMemo(
debounce((command: string) => { () =>
debounce((groupId: string, command: string, mutate: any) => {
mutate({ id: groupId, payload: { command } });
}, 950),
[],
);
const handleUpdateOneliner = useCallback(
(oneliner: string) => {
if (!group || !group._id) { if (!group || !group._id) {
return console.warn('Group not found'); return console.warn('Group not found');
} }
updateGroupMutation.mutate({ id: group._id, payload: { command } }); debouncedUpdateOneliner(group._id, oneliner, updateGroupMutation.mutate);
}, 950), },
[updateGroupMutation, group], [group, updateGroupMutation.mutate, debouncedUpdateOneliner],
);
const handleUpdateCommand = useCallback(
(command: string) => {
if (!group || !group._id) {
return console.warn('Group not found');
}
debouncedUpdateCommand(group._id, command, updateGroupMutation.mutate);
},
[group, updateGroupMutation.mutate, debouncedUpdateCommand],
); );
if (initialLoad) { if (initialLoad) {
@ -217,89 +350,7 @@ const PromptForm = () => {
return null; return null;
} }
const groupId = group._id;
const groupName = group.name; const groupName = group.name;
const groupCategory = group.category;
const RightPanel = () => (
<div
className="h-full w-full overflow-y-auto bg-surface-primary px-4"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
<CategorySelector
currentCategory={groupCategory}
onValueChange={(value) =>
updateGroupMutation.mutate({
id: groupId,
payload: { name: groupName, category: value },
})
}
/>
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && (
<Button
variant="submit"
size="sm"
aria-label="Make prompt production"
className="h-10 w-10 border border-transparent p-0.5 transition-all"
onClick={() => {
if (!selectedPrompt) {
console.warn('No prompt is selected');
return;
}
const { _id: promptVersionId = '', prompt } = selectedPrompt;
makeProductionMutation.mutate({
id: promptVersionId,
groupId,
productionPrompt: { prompt },
});
}}
disabled={
isLoadingGroup ||
!selectedPrompt ||
selectedPrompt._id === group.productionId ||
makeProductionMutation.isLoading
}
>
<Rocket className="size-5 cursor-pointer text-white" />
</Button>
)}
<DeleteConfirm
name={groupName}
disabled={isLoadingGroup}
selectHandler={() => {
if (!selectedPrompt || !selectedPrompt._id) {
console.warn('No prompt is selected or prompt _id is missing');
return;
}
deletePromptMutation.mutate({
_id: selectedPrompt._id,
groupId,
});
}}
/>
</div>
</div>
{editorMode === PromptsEditorMode.ADVANCED &&
(isLoadingPrompts
? Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
<Skeleton className="h-[72px] w-full" />
</div>
))
: prompts.length > 0 && (
<PromptVersions
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
setSelectionIndex={setSelectionIndex}
/>
))}
</div>
);
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
@ -339,7 +390,17 @@ const PromptForm = () => {
<Menu className="size-5" /> <Menu className="size-5" />
</Button> </Button>
<div className="hidden lg:block"> <div className="hidden lg:block">
{editorMode === PromptsEditorMode.SIMPLE && <RightPanel />} {editorMode === PromptsEditorMode.SIMPLE && (
<RightPanel
group={group}
prompts={prompts}
selectedPrompt={selectedPrompt}
selectionIndex={selectionIndex}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
)}
</div> </div>
</> </>
)} )}
@ -352,11 +413,11 @@ const PromptForm = () => {
<PromptVariables promptText={promptText} /> <PromptVariables promptText={promptText} />
<Description <Description
initialValue={group.oneliner ?? ''} initialValue={group.oneliner ?? ''}
onValueChange={debouncedUpdateOneliner} onValueChange={handleUpdateOneliner}
/> />
<Command <Command
initialValue={group.command ?? ''} initialValue={group.command ?? ''}
onValueChange={debouncedUpdateCommand} onValueChange={handleUpdateCommand}
/> />
</div> </div>
)} )}
@ -364,7 +425,15 @@ const PromptForm = () => {
{editorMode === PromptsEditorMode.ADVANCED && ( {editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-1/4 border-l border-border-light lg:block"> <div className="hidden w-1/4 border-l border-border-light lg:block">
<RightPanel /> <RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
</div> </div>
)} )}
</div> </div>
@ -395,7 +464,15 @@ const PromptForm = () => {
> >
<div className="h-full"> <div className="h-full">
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<RightPanel /> <RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -4,18 +4,18 @@ import { Plus } from 'lucide-react';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider'; import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import { import {
Spinner,
EditIcon,
TrashIcon,
Table, Table,
Input, Input,
Label, Label,
Button, Button,
Switch, Switch,
Spinner,
TableRow, TableRow,
OGDialog, OGDialog,
EditIcon,
TableHead, TableHead,
TableBody, TableBody,
TrashIcon,
TableCell, TableCell,
TableHeader, TableHeader,
TooltipAnchor, TooltipAnchor,
@ -25,10 +25,10 @@ import {
} from '@librechat/client'; } from '@librechat/client';
import type { TUserMemory } from 'librechat-data-provider'; import type { TUserMemory } from 'librechat-data-provider';
import { import {
useGetUserQuery,
useMemoriesQuery,
useDeleteMemoryMutation,
useUpdateMemoryPreferencesMutation, useUpdateMemoryPreferencesMutation,
useDeleteMemoryMutation,
useMemoriesQuery,
useGetUserQuery,
} from '~/data-provider'; } from '~/data-provider';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks'; import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import MemoryCreateDialog from './MemoryCreateDialog'; import MemoryCreateDialog from './MemoryCreateDialog';
@ -36,18 +36,114 @@ import MemoryEditDialog from './MemoryEditDialog';
import AdminSettings from './AdminSettings'; import AdminSettings from './AdminSettings';
import { cn } from '~/utils'; import { cn } from '~/utils';
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
return (
<MemoryEditDialog
open={open}
memory={memory}
onOpenChange={setOpen}
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<EditIcon />
</Button>
}
/>
</OGDialogTrigger>
</MemoryEditDialog>
);
};
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const [open, setOpen] = useState(false);
const { mutate: deleteMemory } = useDeleteMemoryMutation();
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const confirmDelete = async () => {
setDeletingKey(memory.key);
deleteMemory(memory.key, {
onSuccess: () => {
showToast({
message: localize('com_ui_deleted'),
status: 'success',
});
setOpen(false);
},
onError: () =>
showToast({
message: localize('com_ui_error'),
status: 'error',
}),
onSettled: () => setDeletingKey(null),
});
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_delete')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
{deletingKey === memory.key ? (
<Spinner className="size-4 animate-spin" />
) : (
<TrashIcon className="size-4" />
)}
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_memory')}
className="w-11/12 max-w-lg"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} &quot;{memory.key}&quot;?
</Label>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
};
const pageSize = 10;
export default function MemoryViewer() { export default function MemoryViewer() {
const localize = useLocalize(); const localize = useLocalize();
const { user } = useAuthContext(); const { user } = useAuthContext();
const { data: userData } = useGetUserQuery(); const { data: userData } = useGetUserQuery();
const { data: memData, isLoading } = useMemoriesQuery(); const { data: memData, isLoading } = useMemoriesQuery();
const { mutate: deleteMemory } = useDeleteMemoryMutation();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const pageSize = 10;
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true); const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({ const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
@ -119,108 +215,6 @@ export default function MemoryViewer() {
return 'stroke-green-500'; return 'stroke-green-500';
}; };
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
// Only show edit button if user has UPDATE permission
if (!hasUpdateAccess) {
return null;
}
return (
<MemoryEditDialog
open={open}
memory={memory}
onOpenChange={setOpen}
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<EditIcon />
</Button>
}
/>
</OGDialogTrigger>
</MemoryEditDialog>
);
};
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const [open, setOpen] = useState(false);
if (!hasUpdateAccess) {
return null;
}
const confirmDelete = async () => {
setDeletingKey(memory.key);
deleteMemory(memory.key, {
onSuccess: () => {
showToast({
message: localize('com_ui_deleted'),
status: 'success',
});
setOpen(false);
},
onError: () =>
showToast({
message: localize('com_ui_error'),
status: 'error',
}),
onSettled: () => setDeletingKey(null),
});
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_delete')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
{deletingKey === memory.key ? (
<Spinner className="size-4 animate-spin" />
) : (
<TrashIcon className="size-4" />
)}
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_memory')}
className="w-11/12 max-w-lg"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} &quot;{memory.key}&quot;?
</Label>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full w-full items-center justify-center p-4"> <div className="flex h-full w-full items-center justify-center p-4">