Skip to content

Commit

Permalink
Folders: change Folder from the Drawer Chat Item as well, #321
Browse files Browse the repository at this point in the history
  • Loading branch information
enricoros committed Jan 24, 2024
1 parent 8800cae commit 4a35701
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 30 deletions.
93 changes: 84 additions & 9 deletions src/apps/chat/components/applayout/ChatDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,39 1,45 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';

import { Box, IconButton, ListDivider, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
import { Box, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';

import DebounceInput from '~/common/components/DebounceInput';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
import { PageDrawerList, PageDrawerTallItemSx } from '~/common/layout/optima/components/PageDrawerList';
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import DebounceInput from '~/common/components/DebounceInput';

import { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
import { ChatFolderList } from './folder/ChatFolderList';
import { ChatDrawerItemMemo, ChatNavigationItemData } from './ChatNavigationItem';
import { ClearFolderText } from './folder/useFolderDropdown';


// this is here to make shallow comparisons work on the next hook
const noFolders: DFolder[] = [];

/*
* Lists folders and returns the active folder
*/
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders: allFolders, toggleEnableFolders }) => {
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders, toggleEnableFolders }) => {

// finds the active folder if any
const activeFolder = activeFolderId
? allFolders.find(folder => folder.id === activeFolderId) ?? null
const activeFolder = (enableFolders && activeFolderId)
? folders.find(folder => folder.id === activeFolderId) ?? null
: null;

return {
activeFolder,
allFolders,
allFolders: enableFolders ? folders : noFolders,
enableFolders,
toggleEnableFolders,
};
Expand All @@ -44,7 50,7 @@ export const useFolders = (activeFolderId: string | null) => useFolderStore(({ e
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
* to avoid unnecessary re-renders on each new character typed by the assistant
*/
export const useChatNavigationItemsData = (activeFolder: DFolder | null, activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
useChatStore(({ conversations }) => {

const activeConversations = activeFolder
Expand All @@ -56,6 62,11 @@ export const useChatNavigationItemsData = (activeFolder: DFolder | null, activeC
isActive: _c.id === activeConversationId,
isEmpty: !_c.messages.length && !_c.userTitle,
title: conversationTitle(_c),
folder: !allFolders.length
? undefined // don't show folder select if folders are disabled
: _c.id === activeConversationId // only show the folder for active conversation(s)
? allFolders.find(folder => folder.conversationIds.includes(_c.id)) ?? null
: null,
messageCount: _c.messages.length,
assistantTyping: !!_c.abortController,
systemPurposeId: _c.systemPurposeId,
Expand Down Expand Up @@ -86,11 97,12 @@ function ChatDrawer(props: {

// local state
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);

// external state
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
const chatNavItems = useChatNavigationItemsData(activeFolder, props.activeConversationId);
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId);
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');

// derived state
Expand All @@ -105,17 117,38 @@ function ChatDrawer(props: {
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationNew]);


const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
onConversationActivate(conversationId);
if (closeMenu)
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationActivate]);


const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
!singleChat && conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete, singleChat]);


// Folder change request

const handleConversationFolderChange = React.useCallback((folderChangeRequest: FolderChangeRequest) => setFolderChangeRequest(folderChangeRequest), []);

const handleConversationFolderCancel = React.useCallback(() => setFolderChangeRequest(null), []);

const handleConversationFolderSet = React.useCallback((conversationId: DConversationId, nextFolderId: string | null) => {
// Remove conversation from existing folders
const { addConversationToFolder, folders, removeConversationFromFolder } = useFolderStore.getState();
folders.forEach(folder => folder.conversationIds.includes(conversationId) && removeConversationFromFolder(folder.id, conversationId));

// Add conversation to the selected folder
nextFolderId && addConversationToFolder(nextFolderId, conversationId);

// Close the menu
setFolderChangeRequest(null);
}, []);


// Filter chatNavItems based on the search query and rank them by search frequency
const filteredChatNavItems = React.useMemo(() => {
if (!debouncedSearchQuery) return chatNavItems;
Expand Down Expand Up @@ -257,6 290,7 @@ function ChatDrawer(props: {
bottomBarBasis={(softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
onConversationActivate={handleConversationActivate}
onConversationDelete={handleConversationDelete}
onConversationFolderChange={handleConversationFolderChange}
/>)}
</Box>

Expand Down Expand Up @@ -289,5 323,46 @@ function ChatDrawer(props: {

</PageDrawerList>


{/* [Menu] Chat Item Folder Change */}
{!!folderChangeRequest?.anchorEl && (
<CloseableMenu
open anchorEl={folderChangeRequest.anchorEl} onClose={handleConversationFolderCancel}
placement='bottom-start'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>

{/* Folder Assignment Buttons */}
{allFolders.map(folder => {
const isRequestFolder = folder === folderChangeRequest.currentFolder;
return (
<ListItem
key={folder.id}
variant={isRequestFolder ? 'soft' : 'plain'}
onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, folder.id)}
>
<ListItemButton>
<ListItemDecorator>
<FolderIcon sx={{ color: folder.color }} />
</ListItemDecorator>
{folder.title}
</ListItemButton>
</ListItem>
);
})}

{/* Remove Folder Assignment */}
{!!folderChangeRequest.currentFolder && (
<ListItem onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, null)}>
<ListItemButton>
{ClearFolderText}
</ListItemButton>
</ListItem>
)}

</CloseableMenu>
)}

</>;
}
Original file line number Diff line number Diff line change
@@ -1,16 1,19 @@
import * as React from 'react';

import { Avatar, Box, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
import { Avatar, Box, Divider, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import CloseIcon from '@mui/icons-material/Close';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditIcon from '@mui/icons-material/Edit';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';

import { SystemPurposeId, SystemPurposes } from '../../../../data';

import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';

import type { DFolder } from '~/common/state/store-folders';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { InlineTextarea } from '~/common/components/InlineTextarea';

Expand All @@ -19,41 22,49 @@ import { InlineTextarea } from '~/common/components/InlineTextarea';
// const DEBUG_CONVERSATION_IDS = false;


const FadeInButton = styled(IconButton)({
opacity: 0.5,
export const FadeInButton = styled(IconButton)({
opacity: 0.6,
transition: 'opacity 0.2s',
'&:hover': { opacity: 1 },
});


export const ChatDrawerItemMemo = React.memo(ChatNavigationItem);
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem);

export interface ChatNavigationItemData {
conversationId: DConversationId;
isActive: boolean;
isEmpty: boolean;
title: string;
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
messageCount: number;
assistantTyping: boolean;
systemPurposeId: SystemPurposeId;
searchFrequency?: number;
}

function ChatNavigationItem(props: {
export interface FolderChangeRequest {
conversationId: DConversationId;
anchorEl: HTMLButtonElement;
currentFolder: DFolder | null;
}

function ChatDrawerItem(props: {
item: ChatNavigationItemData,
isLonely: boolean,
showSymbols: boolean,
bottomBarBasis: number,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationDelete: (conversationId: DConversationId) => void,
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
}) {

// state
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [deleteArmed, setDeleteArmed] = React.useState(false);

// derived state
const { conversationId, isActive, title, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const { conversationId, isActive, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const isNew = messageCount === 0;


Expand All @@ -70,6 81,20 @@ function ChatNavigationItem(props: {
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);


// Folder change

const { onConversationFolderChange } = props;

const handleFolderChangeBegin = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onConversationFolderChange({
conversationId,
anchorEl: event.currentTarget,
currentFolder: folder ?? null,
});
}, [conversationId, folder, onConversationFolderChange]);


// Title Edit

const handleTitleEditBegin = React.useCallback(() => setIsEditingTitle(true), []);
Expand Down Expand Up @@ -176,7 201,7 @@ function ChatNavigationItem(props: {
), [progress]);


return isActive ?
return isActive ? (

// Active Conversation
<Sheet
Expand All @@ -198,26 223,36 @@ function ChatNavigationItem(props: {

<ListItem sx={{ border: 'none', display: 'grid', gap: 0, px: 'calc(var(--ListItem-paddingX) - 0.25rem)' }}>

{/* title row */}
{/* Title row */}
<Box sx={{ display: 'flex', gap: 'var(--ListItem-gap)', minHeight: '2.25rem', alignItems: 'center' }}>

{titleRowComponent}

</Box>

{/* buttons row */}
<Box sx={{ display: 'flex', gap: 'var(--ListItem-gap)', minHeight: '2.25rem', alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1, minHeight: '2.25rem', alignItems: 'center' }}>

<ListItemDecorator />

<Tooltip title='Rename Chat'>
{/* Current Folder color, and change initiator */}
{(folder !== undefined) && <>
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
<FadeInButton onClick={handleFolderChangeBegin}>
{folder ? <FolderIcon style={{ color: folder?.color || 'inherit' }} /> : <FolderOutlinedIcon />}
</FadeInButton>
</Tooltip>
<Divider orientation='vertical' sx={{ my: 1 }} />
</>}

<Tooltip disableInteractive title='Rename Chat'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditBegin}>
<EditIcon />
</FadeInButton>
</Tooltip>

{!isNew && (
<Tooltip title='Auto-title Chat'>
<Tooltip disableInteractive title='Auto-title Chat'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
<AutoFixHighIcon />
</FadeInButton>
Expand All @@ -227,17 262,17 @@ function ChatNavigationItem(props: {
{/* --> */}
<Box sx={{ flex: 1 }} />

{/* Delete Button(s) */}
{/* Delete [armed, arming] buttons */}
{!props.isLonely && !searchFrequency && <>
{deleteArmed && (
<Tooltip title='Confirm Deletion'>
<Tooltip disableInteractive title='Confirm Deletion'>
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1 }}>
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
</FadeInButton>
</Tooltip>
)}

<Tooltip title={deleteArmed ? 'Cancel' : 'Delete?'}>
<Tooltip disableInteractive title={deleteArmed ? 'Cancel' : 'Delete?'}>
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
{deleteArmed ? <CloseIcon /> : <DeleteOutlineIcon />}
</FadeInButton>
Expand All @@ -253,7 288,7 @@ function ChatNavigationItem(props: {

</Sheet>

:
) : (

// Inactive Conversation - click to activate
<ListItemButton
Expand All @@ -270,5 305,6 @@ function ChatNavigationItem(props: {
{/* Optional progress bar, underlay */}
{progressBarFixedComponent}

</ListItemButton>;
</ListItemButton>
);
}
3 changes: 2 additions & 1 deletion src/apps/chat/components/applayout/folder/FolderListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 200,8 @@ export function FolderListItem(props: {
{!!menuAnchorEl && (
<CloseableMenu
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
placement='top' zIndex={1301 /* need to be on top of the Modal on Mobile */}
placement='top'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>

Expand Down
Loading

0 comments on commit 4a35701

Please sign in to comment.