From 76778c5ab76ab3fcfa07a5cb93954b35ca8aac5e Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Fri, 29 Dec 2023 04:01:23 -0800 Subject: [PATCH] Action Tiles framework - for commands and attachments --- .../chat/components/composer/Composer.tsx | 77 +++++++++--- .../composer/actile/ActilePopup.tsx | 81 ++++++++++++ .../composer/actile/ActileProvider.tsx | 21 ++++ .../actile/providerAttachReference.tsx | 23 ++++ .../composer/actile/providerCommands.tsx | 23 ++++ .../composer/actile/useActileManager.tsx | 118 ++++++++++++++++++ src/common/components/CloseableMenu.tsx | 2 + 7 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 src/apps/chat/components/composer/actile/ActilePopup.tsx create mode 100644 src/apps/chat/components/composer/actile/ActileProvider.tsx create mode 100644 src/apps/chat/components/composer/actile/providerAttachReference.tsx create mode 100644 src/apps/chat/components/composer/actile/providerCommands.tsx create mode 100644 src/apps/chat/components/composer/actile/useActileManager.tsx diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx index b233b98b9..41640f074 100644 --- a/src/apps/chat/components/composer/Composer.tsx +++ b/src/apps/chat/components/composer/Composer.tsx @@ -36,6 +36,10 @@ import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout'; import { useUIPreferencesStore } from '~/common/state/store-ui'; import { useUXLabsStore } from '~/common/state/store-ux-labs'; +import type { ActileItem, ActileProvider } from './actile/ActileProvider'; +import { providerCommands } from './actile/providerCommands'; +import { useActileManager } from './actile/useActileManager'; + import type { AttachmentId } from './attachments/store-attachments'; import { Attachments } from './attachments/Attachments'; import { getTextBlockText, useLLMAttachments } from './attachments/useLLMAttachments'; @@ -187,13 +191,61 @@ export function Composer(props: { }; - // Text actions + // Mode menu + + const handleModeSelectorHide = () => setChatModeMenuAnchor(null); + + const handleModeSelectorShow = (event: React.MouseEvent) => + setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget); + + const handleModeChange = (_chatModeId: ChatModeId) => { + handleModeSelectorHide(); + setChatModeId(_chatModeId); + }; + + + // Actiles + + const onActileCommandSelect = React.useCallback((item: ActileItem) => { + if (props.composerTextAreaRef.current) { + const textArea = props.composerTextAreaRef.current; + const currentText = textArea.value; + const cursorPos = textArea.selectionStart; + + // Find the position where the command starts + const commandStart = currentText.lastIndexOf('/', cursorPos); + + // Construct the new text with the autocompleted command + const newText = currentText.substring(0, commandStart) + item.label + ' ' + currentText.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); + } + }, [props.composerTextAreaRef, setComposeText]); - const handleTextAreaTextChange = React.useCallback((e: React.ChangeEvent) => { + const actileProviders: ActileProvider[] = React.useMemo(() => { + return [providerCommands(onActileCommandSelect)]; + }, [onActileCommandSelect]); + + const { actileComponent, actileInterceptKeydown } = useActileManager(actileProviders, props.composerTextAreaRef); + + + // Text typing + + const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent) => { setComposeText(e.target.value); }, [setComposeText]); - const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent) => { + const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent) => { + // disable keyboard handling if the actile is visible + if (actileInterceptKeydown(e)) + return; + + // Enter: primary action if (e.key === 'Enter') { // Alt: append the message instead @@ -209,20 +261,8 @@ export function Composer(props: { return e.preventDefault(); } } - }, [assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]); - - // Mode menu - - const handleModeSelectorHide = () => setChatModeMenuAnchor(null); - - const handleModeSelectorShow = (event: React.MouseEvent) => - setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget); - - const handleModeChange = (_chatModeId: ChatModeId) => { - handleModeSelectorHide(); - setChatModeId(_chatModeId); - }; + }, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]); // Mic typing & continuation mode @@ -453,7 +493,7 @@ export function Composer(props: { minRows={isMobile ? 5 : 5} maxRows={10} placeholder={textPlaceholder} value={composeText} - onChange={handleTextAreaTextChange} + onChange={handleTextareaTextChange} onDragEnter={handleTextareaDragEnter} onDragStart={handleTextareaDragStart} onKeyDown={handleTextareaKeyDown} @@ -663,6 +703,9 @@ export function Composer(props: { /> )} + {/* Actile */} + {actileComponent} + ); diff --git a/src/apps/chat/components/composer/actile/ActilePopup.tsx b/src/apps/chat/components/composer/actile/ActilePopup.tsx new file mode 100644 index 000000000..2e4396928 --- /dev/null +++ b/src/apps/chat/components/composer/actile/ActilePopup.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; + +import { Box, ListItem, ListItemButton, ListItemDecorator, Sheet, Typography } from '@mui/joy'; + +import { CloseableMenu } from '~/common/components/CloseableMenu'; + +import type { ActileItem } from './ActileProvider'; + + +export function ActilePopup(props: { + anchorEl: HTMLElement | null, + onClose: () => void, + title?: string, + items: ActileItem[], + activeItemIndex: number | undefined, + activePrefixLength: number, + onItemClick: (item: ActileItem) => void, + children?: React.ReactNode +}) { + + const hasAnyIcon = props.items.some(item => !!item.Icon); + + return ( + + + {!!props.title && ( + + {/**/} + + {props.title} + + + )} + + {!props.items.length && ( + + + No matching command + + + )} + + {props.items.map((item, idx) => { + const labelBold = item.label.slice(0, props.activePrefixLength); + const labelNormal = item.label.slice(props.activePrefixLength); + return ( + props.onItemClick(item)} + > + {hasAnyIcon && ( + + {item.Icon ? : null} + + )} + + + + + {labelBold}{labelNormal} + + {item.argument && + {item.argument} + } + + + {!!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 new file mode 100644 index 000000000..175eb59a5 --- /dev/null +++ b/src/apps/chat/components/composer/actile/ActileProvider.tsx @@ -0,0 +1,21 @@ +import type { FunctionComponent } from 'react'; + +export interface ActileItem { + id: string; + label: string; + argument?: string; + description?: string; + Icon?: FunctionComponent; +} + +type ActileProviderIds = 'actile-commands' | 'actile-attach-reference'; + +export interface ActileProvider { + id: ActileProviderIds; + title: string; + + checkTriggerText: (trailingText: string) => boolean; + + fetchItems: () => Promise; + onItemSelect: (item: ActileItem) => void; +} diff --git a/src/apps/chat/components/composer/actile/providerAttachReference.tsx b/src/apps/chat/components/composer/actile/providerAttachReference.tsx new file mode 100644 index 000000000..f81f1789a --- /dev/null +++ b/src/apps/chat/components/composer/actile/providerAttachReference.tsx @@ -0,0 +1,23 @@ +import { ActileItem, ActileProvider } from './ActileProvider'; + + +export const providerAttachReference: ActileProvider = { + id: 'actile-attach-reference', + title: 'Attach Reference', + + checkTriggerText: (trailingText: string) => + trailingText.endsWith(' @'), + + fetchItems: async () => { + return [{ + id: 'test-1', + label: 'Attach This', + description: 'Attach this to the message', + Icon: undefined, + }]; + }, + + onItemSelect: (item: ActileItem) => { + console.log('Selected item:', item); + }, +}; \ 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 new file mode 100644 index 000000000..9f28f903e --- /dev/null +++ b/src/apps/chat/components/composer/actile/providerCommands.tsx @@ -0,0 +1,23 @@ +import { ActileItem, ActileProvider } from './ActileProvider'; +import { findAllChatCommands } from '../../../commands/commands.registry'; + + +export const providerCommands = (onItemSelect: (item: ActileItem) => void): ActileProvider => ({ + id: 'actile-commands', + title: 'Chat Commands', + + checkTriggerText: (trailingText: string) => + trailingText.trim() === '/', + + fetchItems: async () => { + return findAllChatCommands().map((cmd) => ({ + id: cmd.primary, + label: cmd.primary, + argument: cmd.arguments?.join(' ') ?? undefined, + description: cmd.description, + Icon: cmd.Icon, + })); + }, + + onItemSelect, +}); \ 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 new file mode 100644 index 000000000..52df81959 --- /dev/null +++ b/src/apps/chat/components/composer/actile/useActileManager.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { ActileItem, ActileProvider } from './ActileProvider'; +import { ActilePopup } from './ActilePopup'; + + +export const useActileManager = (providers: ActileProvider[], anchorRef: React.RefObject) => { + + // state + const [popupOpen, setPopupOpen] = React.useState(false); + const [provider, setProvider] = React.useState(null); + + const [items, setItems] = React.useState([]); + const [activeSearchString, setActiveSearchString] = React.useState(''); + const [activeItemIndex, setActiveItemIndex] = React.useState(0); + + + // derived state + const activeItems = 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; + + + const handleClose = React.useCallback(() => { + setPopupOpen(false); + setProvider(null); + setItems([]); + setActiveSearchString(''); + setActiveItemIndex(0); + }, []); + + const handlePopupItemClicked = React.useCallback((item: ActileItem) => { + provider?.onItemSelect(item); + handleClose(); + }, [handleClose, provider]); + + const handleEnterKey = React.useCallback(() => { + activeItem && handlePopupItemClicked(activeItem); + }, [activeItem, handlePopupItemClicked]); + + + const actileInterceptKeydown = React.useCallback((_event: React.KeyboardEvent): boolean => { + + // Popup open: Intercept + + const { key, currentTarget, ctrlKey, metaKey } = _event; + + if (popupOpen) { + if (key === 'Escape' || key === 'ArrowLeft') { + _event.preventDefault(); + handleClose(); + } else if (key === 'ArrowUp') { + _event.preventDefault(); + setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : activeItems.length - 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)) { + _event.preventDefault(); + handleEnterKey(); + } else if (key === 'Backspace') { + handleClose(); + } else if (key.length === 1 && !ctrlKey && !metaKey) { + setActiveSearchString((prev) => prev + key); + setActiveItemIndex(0); + } + return true; + } + + // Popup closed: Check for triggers + + // optimization + if (key !== '/' && key !== '@') + return false; + + const trailingText = (currentTarget.value || '') + key; + + // check all rules to find one that triggers + for (const provider of providers) { + if (provider.checkTriggerText(trailingText)) { + setProvider(provider); + setPopupOpen(true); + setActiveSearchString(key); + provider + .fetchItems() + .then(items => setItems(items)) + .catch(error => { + handleClose(); + console.error('Failed to fetch popup items:', error); + }); + return true; + } + } + + return false; + }, [activeItems.length, handleClose, handleEnterKey, popupOpen, providers]); + + + const actileComponent = React.useMemo(() => { + return !popupOpen ? null : ( + + ); + }, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, provider?.title]); + + return { + actileComponent, + actileInterceptKeydown, + }; +}; \ No newline at end of file diff --git a/src/common/components/CloseableMenu.tsx b/src/common/components/CloseableMenu.tsx index 0142ef21f..fce19a9eb 100644 --- a/src/common/components/CloseableMenu.tsx +++ b/src/common/components/CloseableMenu.tsx @@ -33,6 +33,7 @@ export function CloseableMenu(props: { noBottomPadding?: boolean, sx?: SxProps, zIndex?: number, + listRef?: React.Ref, children?: React.ReactNode, }) { @@ -71,6 +72,7 @@ export function CloseableMenu(props: { >