diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx
index e716d8386..b465e18f2 100644
--- a/src/apps/chat/components/composer/Composer.tsx
+++ b/src/apps/chat/components/composer/Composer.tsx
@@ -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);
diff --git a/src/apps/chat/components/composer/actile/ActilePopup.tsx b/src/apps/chat/components/composer/actile/ActilePopup.tsx
index 5c904d59a..18b664e76 100644
--- a/src/apps/chat/components/composer/actile/ActilePopup.tsx
+++ b/src/apps/chat/components/composer/actile/ActilePopup.tsx
@@ -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 (
- {!!props.title && (
-
-
- {props.title}
-
-
- )}
-
- {!props.items.length && (
+ {!props.itemsByProvider.length && (
No matching command
@@ -43,46 +49,65 @@ export function ActilePopup(props: {
)}
- {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 (
- props.onItemClick(item)}
- >
-
- {hasAnyIcon && (
-
- {item.Icon ? : null}
-
- )}
-
+ {props.itemsByProvider.map(({ provider, items }) => (
+
-
-
- {labelBold}{labelNormal}
-
- {item.argument &&
- {item.argument}
+ {/* Provider Label */}
+
+
+ {provider.label}
+
+
+
+ {/* 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 (
+ props.onItemClick(item)}
+ >
+
+ {item.Icon && (
+
+
+
+ )}
+
+ {/* Item*/}
+
+
+ {/* Item main text */}
+
+
+ {labelBold}{labelNormal}
+
+ {item.argument &&
+ {item.argument}
+ }
+
+
+ {/* Item description */}
+ {!!item.description &&
+ {item.description}
}
+
- {!!item.description &&
- {item.description}
- }
-
-
-
- );
- },
- )}
-
- {props.children}
+
+
+ );
+ })}
+
+ ))}
);
-}
\ No newline at end of file
+}
diff --git a/src/apps/chat/components/composer/actile/ActileProvider.tsx b/src/apps/chat/components/composer/actile/ActileProvider.tsx
index 9b0440c25..e306fcf41 100644
--- a/src/apps/chat/components/composer/actile/ActileProvider.tsx
+++ b/src/apps/chat/components/composer/actile/ActileProvider.tsx
@@ -1,15 +1,27 @@
import type { FunctionComponent } from 'react';
+export interface ActileProvider {
+
+ // 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;
+ onItemSelect: (item: ActileItem) => void;
+
+}
+
+export type ActileProviderItems = Promise<{ searchPrefix: string, items: TItem[] }>;
+
export interface ActileItem {
key: string;
+ providerKey: ActileProvider['key'];
label: string;
argument?: string;
description?: string;
Icon?: FunctionComponent;
}
-
-export interface ActileProvider {
- fastCheckTriggerText: (trailingText: string) => boolean;
- fetchItems: () => Promise<{ title: string, searchPrefix: string, items: TItem[] }>;
- onItemSelect: (item: ActileItem) => void;
-}
diff --git a/src/apps/chat/components/composer/actile/providerAttachmentLabels.tsx b/src/apps/chat/components/composer/actile/providerAttachmentLabels.tsx
new file mode 100644
index 000000000..61e230994
--- /dev/null
+++ b/src/apps/chat/components/composer/actile/providerAttachmentLabels.tsx
@@ -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 => ({
+
+ key: 'pattlbl',
+
+ get label() {
+ return 'Attachment Labels';
+ },
+
+ // Uses '@' as the trigger
+ fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
+
+ fetchItems: async (): ActileProviderItems => ({
+ 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, '@'),
+
+});
\ No newline at end of file
diff --git a/src/apps/chat/components/composer/actile/providerCommands.tsx b/src/apps/chat/components/composer/actile/providerCommands.tsx
index 957731472..4eeb6b68b 100644
--- a/src/apps/chat/components/composer/actile/providerCommands.tsx
+++ b/src/apps/chat/components/composer/actile/providerCommands.tsx
@@ -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,
- };
-}
\ No newline at end of file
+ onItemSelect: (item) => onCommandSelect(item as ActileItem, '/'),
+
+});
\ No newline at end of file
diff --git a/src/apps/chat/components/composer/actile/providerStarredMessage.tsx b/src/apps/chat/components/composer/actile/providerStarredMessage.tsx
index d7525bb43..b45402d29 100644
--- a/src/apps/chat/components/composer/actile/providerStarredMessage.tsx
+++ b/src/apps/chat/components/composer/actile/providerStarredMessage.tsx
@@ -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 {
- return {
+export const providerStarredMessages = (onMessageSelect: (item: StarredMessageItem) => void): ActileProvider => ({
- // 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 => {
+ 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),
- };
-}
\ No newline at end of file
+ onItemSelect: item => onMessageSelect(item as StarredMessageItem),
+
+});
\ No newline at end of file
diff --git a/src/apps/chat/components/composer/actile/useActileManager.tsx b/src/apps/chat/components/composer/actile/useActileManager.tsx
index 80c98572c..1fc750488 100644
--- a/src/apps/chat/components/composer/actile/useActileManager.tsx
+++ b/src/apps/chat/components/composer/actile/useActileManager.tsx
@@ -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(null);
-
- const [title, setTitle] = React.useState('');
- const [items, setItems] = React.useState([]);
+ const [itemsByProvider, setItemsByProvider] = React.useState<{ provider: ActileProvider, items: ActileItem[] }[]>([]);
const [activeSearchString, setActiveSearchString] = React.useState('');
const [activeItemIndex, setActiveItemIndex] = React.useState(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): 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 : (
);
- }, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, title]);
+ }, [activeItemIndex, activeItemsByProvider, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen]);
return {
actileComponent,
actileInterceptKeydown,
actileInterceptTextChange,
};
-};
\ No newline at end of file
+};