Actile: improve, multi-provider, label attachments

This commit is contained in:
Enrico Ros
2024-10-09 01:17:19 -07:00
parent 36eda51789
commit 41a2f1e526
7 changed files with 262 additions and 171 deletions
+14 -13
View File
@@ -49,8 +49,9 @@ import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ActileItem } from './actile/ActileProvider';
import { providerAttachmentLabels } from './actile/providerAttachmentLabels';
import { providerCommands } from './actile/providerCommands';
import { providerStarredMessage, StarredMessageItem } from './actile/providerStarredMessage';
import { providerStarredMessages, StarredMessageItem } from './actile/providerStarredMessage';
import { useActileManager } from './actile/useActileManager';
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
@@ -457,24 +458,21 @@ export function Composer(props: {
// Actiles
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
const onActileCommandPaste = React.useCallback(({ label }: ActileItem, searchPrefix: string) => {
if (composerTextAreaRef.current) {
const textArea = composerTextAreaRef.current;
const currentText = textArea.value;
const cursorPos = textArea.selectionStart;
// Find the position where the command starts
const commandStart = currentText.lastIndexOf('/', cursorPos);
const commandStart = currentText.lastIndexOf(searchPrefix, cursorPos);
// Construct the new text with the autocompleted command
const newText = currentText.substring(0, commandStart) + item.label + ' ' + currentText.substring(cursorPos);
setComposeText((prevText) => prevText.substring(0, commandStart) + label + ' ' + prevText.substring(cursorPos));
// Update the text area with the new text
setComposeText(newText);
// Move the cursor to the end of the autocompleted command
const newCursorPos = commandStart + item.label.length + 1;
textArea.setSelectionRange(newCursorPos, newCursorPos);
// Schedule setting the cursor position after the state update
const newCursorPos = commandStart + label.length + 1;
setTimeout(() => composerTextAreaRef.current?.setSelectionRange(newCursorPos, newCursorPos), 0);
}
}, [composerTextAreaRef, setComposeText]);
@@ -493,9 +491,12 @@ export function Composer(props: {
}
}, [attachAppendEgoFragments]);
const actileProviders = React.useMemo(() => {
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileEmbedMessage)];
}, [onActileCommandPaste, onActileEmbedMessage]);
const actileProviders = React.useMemo(() => [
providerAttachmentLabels(conversationOverlayStore, onActileCommandPaste),
providerCommands(onActileCommandPaste),
providerStarredMessages(onActileEmbedMessage),
], [conversationOverlayStore, onActileCommandPaste, onActileEmbedMessage]);
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, composerTextAreaRef);
@@ -4,38 +4,44 @@ import { Box, ListItem, ListItemButton, ListItemDecorator, Sheet, Typography } f
import { CloseableMenu } from '~/common/components/CloseableMenu';
import type { ActileItem } from './ActileProvider';
import type { ActileItem, ActileProvider } from './ActileProvider';
export function ActilePopup(props: {
anchorEl: HTMLElement | null,
onClose: () => void,
title?: string,
items: ActileItem[],
activeItemIndex: number | undefined,
itemsByProvider: { provider: ActileProvider, items: ActileItem[] }[],
activeItemIndex: number,
activePrefixLength: number,
onItemClick: (item: ActileItem) => void,
children?: React.ReactNode
}) {
const hasAnyIcon = props.items.some(item => !!item.Icon);
// We need to keep track of the overall item index to correctly match with activeItemIndex
const itemIndices = React.useMemo(() => {
const indices: { providerKey: string, itemKey: string, isActive: boolean }[] = [];
let indexCounter = 0;
props.itemsByProvider.forEach(({ provider, items }) => {
items.forEach((item) => {
indices.push({
providerKey: provider.key,
itemKey: item.key,
isActive: indexCounter === props.activeItemIndex,
});
indexCounter += 1;
});
});
return indices;
}, [props.itemsByProvider, props.activeItemIndex]);
return (
<CloseableMenu
noTopPadding noBottomPadding
open anchorEl={props.anchorEl} onClose={props.onClose}
noTopPadding
noBottomPadding
maxHeightGapPx={320}
sx={{ minWidth: 320 }}
>
{!!props.title && (
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
<Typography level='title-sm'>
{props.title}
</Typography>
</Sheet>
)}
{!props.items.length && (
{!props.itemsByProvider.length && (
<ListItem variant='soft' color='warning'>
<Typography level='body-md'>
No matching command
@@ -43,46 +49,65 @@ export function ActilePopup(props: {
</ListItem>
)}
{props.items.map((item, idx) => {
const isActive = idx === props.activeItemIndex;
const labelBold = item.label.slice(0, props.activePrefixLength);
const labelNormal = item.label.slice(props.activePrefixLength);
return (
<ListItem
key={item.key}
variant={isActive ? 'soft' : undefined}
color={isActive ? 'primary' : undefined}
onClick={() => props.onItemClick(item)}
>
<ListItemButton color='primary'>
{hasAnyIcon && (
<ListItemDecorator>
{item.Icon ? <item.Icon /> : null}
</ListItemDecorator>
)}
<Box>
{props.itemsByProvider.map(({ provider, items }) => (
<React.Fragment key={provider.key}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
<span style={{ textDecoration: 'underline' }}><b>{labelBold}</b></span>{labelNormal}
</Typography>
{item.argument && <Typography level='body-sm'>
{item.argument}
{/* Provider Label */}
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
<Typography level='title-sm'>
{provider.label}
</Typography>
</Sheet>
{/* Items */}
{items.map((item) => {
const index = itemIndices.findIndex(idx => idx.providerKey === provider.key && idx.itemKey === item.key);
const isActive = itemIndices[index]?.isActive;
const labelBold = item.label.slice(0, props.activePrefixLength);
const labelNormal = item.label.slice(props.activePrefixLength);
return (
<ListItem
key={`${provider.key}-${item.key}`}
variant={isActive ? 'soft' : undefined}
color={isActive ? 'primary' : undefined}
onClick={() => props.onItemClick(item)}
>
<ListItemButton color='primary'>
{item.Icon && (
<ListItemDecorator>
<item.Icon />
</ListItemDecorator>
)}
{/* Item*/}
<Box>
{/* Item main text */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
<span style={{ textDecoration: 'underline' }}><b>{labelBold}</b></span>{labelNormal}
</Typography>
{item.argument && <Typography level='body-sm'>
{item.argument}
</Typography>}
</Box>
{/* Item description */}
{!!item.description && <Typography level='body-xs'>
{item.description}
</Typography>}
</Box>
{!!item.description && <Typography level='body-xs'>
{item.description}
</Typography>}
</Box>
</ListItemButton>
</ListItem>
);
},
)}
{props.children}
</ListItemButton>
</ListItem>
);
})}
</React.Fragment>
))}
</CloseableMenu>
);
}
}
@@ -1,15 +1,27 @@
import type { FunctionComponent } from 'react';
export interface ActileProvider<TItem extends ActileItem = ActileItem> {
// Unique key for the provider
readonly key: 'pcmd' | 'pstrmsg' | 'pattlbl';
// Label for display
get label(): string;
// Interface for the provider
fastCheckTriggerText: (trailingText: string) => boolean;
fetchItems: () => ActileProviderItems<TItem>;
onItemSelect: (item: ActileItem) => void;
}
export type ActileProviderItems<TItem extends ActileItem = ActileItem> = Promise<{ searchPrefix: string, items: TItem[] }>;
export interface ActileItem {
key: string;
providerKey: ActileProvider['key'];
label: string;
argument?: string;
description?: string;
Icon?: FunctionComponent;
}
export interface ActileProvider<TItem extends ActileItem = ActileItem> {
fastCheckTriggerText: (trailingText: string) => boolean;
fetchItems: () => Promise<{ title: string, searchPrefix: string, items: TItem[] }>;
onItemSelect: (item: ActileItem) => void;
}
@@ -0,0 +1,37 @@
import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider';
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-perchat-attachment-drafts_slice';
export interface AttachmentLabelItem extends ActileItem {
// nothing to do do here, this is really just a label
}
export const providerAttachmentLabels = (
attachmentsStoreApi: AttachmentDraftsStoreApi | null,
onLabelSelect: (item: ActileItem, searchPrefix: string) => void,
): ActileProvider<AttachmentLabelItem> => ({
key: 'pattlbl',
get label() {
return 'Attachment Labels';
},
// Uses '@' as the trigger
fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
fetchItems: async (): ActileProviderItems<AttachmentLabelItem> => ({
searchPrefix: '',
items: attachmentsStoreApi?.getState()?.attachmentDrafts.map(draft => ({
key: draft.id,
providerKey: 'pattlbl',
label: draft.label,
argument: undefined,
description: 'name',
Icon: undefined,
} as AttachmentLabelItem)) ?? [],
}),
onItemSelect: item => onLabelSelect(item as AttachmentLabelItem, '@'),
});
@@ -1,26 +1,35 @@
import { ActileItem, ActileProvider } from './ActileProvider';
import { findAllChatCommands } from '../../../commands/commands.registry';
import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider';
export function providerCommands(onCommandSelect: (item: ActileItem) => void): ActileProvider {
return {
export const providerCommands = (
onCommandSelect: (item: ActileItem, searchPrefix: string) => void,
): ActileProvider => ({
key: 'pcmd',
get label() {
return 'Chat Commands';
},
fastCheckTriggerText: (trailingText: string) => {
// only the literal '/' is a trigger
fastCheckTriggerText: (trailingText: string) => trailingText === '/',
return trailingText === '/';
},
// no real need to be async
fetchItems: async () => ({
title: 'Chat Commands',
searchPrefix: '/',
items: findAllChatCommands().map((cmd) => ({
key: cmd.primary,
label: cmd.primary,
argument: cmd.arguments?.join(' ') ?? undefined,
description: cmd.description,
Icon: cmd.Icon,
} satisfies ActileItem)),
}),
fetchItems: async (): ActileProviderItems => ({
searchPrefix: '/',
items: findAllChatCommands().map((cmd) => ({
key: cmd.primary,
providerKey: 'pcmd',
label: cmd.primary,
argument: cmd.arguments?.join(' ') ?? undefined,
description: cmd.description,
Icon: cmd.Icon,
} satisfies ActileItem)),
}),
onItemSelect: onCommandSelect,
};
}
onItemSelect: (item) => onCommandSelect(item as ActileItem, '/'),
});
@@ -2,7 +2,7 @@ import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.co
import { MESSAGE_FLAG_STARRED, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { ActileItem, ActileProvider } from './ActileProvider';
import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider';
export interface StarredMessageItem extends ActileItem {
@@ -10,39 +10,44 @@ export interface StarredMessageItem extends ActileItem {
messageId: string,
}
export function providerStarredMessage(onMessageSeelect: (item: StarredMessageItem) => void): ActileProvider<StarredMessageItem> {
return {
export const providerStarredMessages = (onMessageSelect: (item: StarredMessageItem) => void): ActileProvider<StarredMessageItem> => ({
// only the literal '@' at start of chat, or ' @' at end of chat
fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
key: 'pstrmsg',
// finds all the starred messages in all the conversations - this could be heavy
fetchItems: async () => {
const { conversations } = useChatStore.getState();
get label() {
return 'Starred Messages';
},
const starredMessages: StarredMessageItem[] = [];
conversations.forEach((conversation) => {
conversation.messages.forEach((message) => {
messageHasUserFlag(message, MESSAGE_FLAG_STARRED) && starredMessages.push({
// data
conversationId: conversation.id,
messageId: message.id,
// looks
key: message.id,
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
// description: message.text.slice(32, 100),
Icon: undefined,
} satisfies StarredMessageItem);
});
// only the literal '@' at start of chat, or ' @' at end of chat
fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
// finds all the starred messages in all the conversations - this could be heavy
fetchItems: async (): ActileProviderItems<StarredMessageItem> => {
const { conversations } = useChatStore.getState();
const starredMessages: StarredMessageItem[] = [];
conversations.forEach((conversation) => {
conversation.messages.forEach((message) => {
messageHasUserFlag(message, MESSAGE_FLAG_STARRED) && starredMessages.push({
key: message.id,
providerKey: 'pstrmsg',
// data
conversationId: conversation.id,
messageId: message.id,
// looks
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
// description: message.text.slice(32, 100),
Icon: undefined,
} satisfies StarredMessageItem);
});
});
return {
title: 'Starred Messages',
searchPrefix: '',
items: starredMessages,
};
},
return {
searchPrefix: '',
items: starredMessages,
};
},
onItemSelect: item => onMessageSeelect(item as StarredMessageItem),
};
}
onItemSelect: item => onMessageSelect(item as StarredMessageItem),
});
@@ -1,5 +1,6 @@
import * as React from 'react';
import { ActileItem, ActileProvider } from './ActileProvider';
import type { ActileItem, ActileProvider } from './ActileProvider';
import { ActilePopup } from './ActilePopup';
@@ -7,71 +8,74 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
// state
const [popupOpen, setPopupOpen] = React.useState(false);
const [provider, setProvider] = React.useState<ActileProvider | null>(null);
const [title, setTitle] = React.useState<string>('');
const [items, setItems] = React.useState<ActileItem[]>([]);
const [itemsByProvider, setItemsByProvider] = React.useState<{ provider: ActileProvider, items: ActileItem[] }[]>([]);
const [activeSearchString, setActiveSearchString] = React.useState<string>('');
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(0);
// derived state
const activeItems = React.useMemo(() => {
const activeItemsByProvider = React.useMemo(() => {
const search = activeSearchString.trim().toLowerCase();
return items.filter(item => item.label?.toLowerCase().startsWith(search));
}, [items, activeSearchString]);
const activeItem = activeItemIndex >= 0 && activeItemIndex < activeItems.length ? activeItems[activeItemIndex] : null;
return itemsByProvider.map(({ provider, items }) => ({
provider,
items: items.filter(item => item.label?.toLowerCase().startsWith(search)),
})).filter(({ items }) => items.length > 0);
}, [itemsByProvider, activeSearchString]);
const flatActiveItems = React.useMemo(() => {
return activeItemsByProvider.flatMap(({ items }) => items);
}, [activeItemsByProvider]);
const totalItems = flatActiveItems.length;
const activeItem = totalItems > 0 && activeItemIndex >= 0 && activeItemIndex < totalItems ? flatActiveItems[activeItemIndex] : null;
const handleClose = React.useCallback(() => {
setPopupOpen(false);
setProvider(null);
setTitle('');
setItems([]);
setItemsByProvider([]);
setActiveSearchString('');
setActiveItemIndex(0);
}, []);
const handlePopupItemClicked = React.useCallback((item: ActileItem) => {
const provider = providers.find(p => p.key === item.providerKey);
provider?.onItemSelect(item);
handleClose();
}, [handleClose, provider]);
}, [providers, handleClose]);
const handleEnterKey = React.useCallback(() => {
activeItem && handlePopupItemClicked(activeItem);
if (activeItem)
handlePopupItemClicked(activeItem);
}, [activeItem, handlePopupItemClicked]);
const actileInterceptTextChange = React.useCallback((trailingText: string) => {
for (const provider of providers) {
if (provider.fastCheckTriggerText(trailingText)) {
provider
.fetchItems()
.then(({ title, searchPrefix, items }) => {
// if there are no items, ignore
if (items.length) {
setPopupOpen(true);
setProvider(provider);
setTitle(title);
setItems(items);
setActiveSearchString(searchPrefix);
}
})
.catch(error => {
handleClose();
console.error('Failed to fetch popup items:', error);
});
return true;
}
// Collect all providers whose trigger matches
const matchingProviders = providers.filter(provider => provider.fastCheckTriggerText(trailingText));
if (matchingProviders.length > 0) {
// Fetch items from all matching providers
Promise.all(matchingProviders.map(provider =>
provider.fetchItems().then(({ searchPrefix, items }) => ({
provider,
searchPrefix,
items: items.map(item => ({ ...item, providerKey: provider.key })),
})),
)).then((results) => {
// Filter out empty results
results = results.filter(result => result.items.length > 0);
if (results.length) {
setPopupOpen(true);
setItemsByProvider(results.map(result => ({ provider: result.provider, items: result.items })));
setActiveSearchString(results[0].searchPrefix); // Assuming all search prefixes are the same
setActiveItemIndex(0);
}
}).catch(error => {
handleClose();
console.error('Failed to fetch popup items:', error);
});
return true;
}
return false;
}, [handleClose, providers]);
const actileInterceptKeydown = React.useCallback((_event: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
// Popup open: Intercept
const { key, currentTarget, ctrlKey, metaKey } = _event;
if (popupOpen) {
@@ -80,11 +84,11 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
handleClose();
} else if (key === 'ArrowUp') {
_event.preventDefault();
setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : activeItems.length - 1));
setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : totalItems - 1));
} else if (key === 'ArrowDown') {
_event.preventDefault();
setActiveItemIndex((prevIndex) => (prevIndex < activeItems.length - 1 ? prevIndex + 1 : 0));
} else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && activeItems.length === 1)) {
setActiveItemIndex((prevIndex) => (prevIndex < totalItems - 1 ? prevIndex + 1 : 0));
} else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && totalItems === 1)) {
_event.preventDefault();
handleEnterKey();
} else if (key === 'Backspace') {
@@ -100,26 +104,24 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
const trailingText = (currentTarget.value || '') + key;
return actileInterceptTextChange(trailingText);
}, [actileInterceptTextChange, activeItems.length, handleClose, handleEnterKey, popupOpen]);
}, [actileInterceptTextChange, handleClose, handleEnterKey, popupOpen, totalItems]);
const actileComponent = React.useMemo(() => {
return !popupOpen ? null : (
<ActilePopup
anchorEl={anchorRef.current}
onClose={handleClose}
title={title}
items={activeItems}
itemsByProvider={activeItemsByProvider}
activeItemIndex={activeItemIndex}
activePrefixLength={activeSearchString.length}
onItemClick={handlePopupItemClicked}
/>
);
}, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, title]);
}, [activeItemIndex, activeItemsByProvider, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen]);
return {
actileComponent,
actileInterceptKeydown,
actileInterceptTextChange,
};
};
};