Skip to content

Commit

Permalink
Action Tiles framework - for commands and attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
enricoros committed Jan 8, 2024
1 parent 11565f5 commit 76778c5
Show file tree
Hide file tree
Showing 7 changed files with 328 additions and 17 deletions.
77 changes: 60 additions & 17 deletions src/apps/chat/components/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -187,13 +191,61 @@ export function Composer(props: {
};


// Text actions
// Mode menu

const handleModeSelectorHide = () => setChatModeMenuAnchor(null);

const handleModeSelectorShow = (event: React.MouseEvent<HTMLAnchorElement>) =>
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
setComposeText(e.target.value);
}, [setComposeText]);

const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent) => {
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// disable keyboard handling if the actile is visible
if (actileInterceptKeydown(e))
return;

// Enter: primary action
if (e.key === 'Enter') {

// Alt: append the message instead
Expand All @@ -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<HTMLAnchorElement>) =>
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);

const handleModeChange = (_chatModeId: ChatModeId) => {
handleModeSelectorHide();
setChatModeId(_chatModeId);
};
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);


// Mic typing & continuation mode
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -663,6 +703,9 @@ export function Composer(props: {
/>
)}

{/* Actile */}
{actileComponent}

</Grid>
</Box>
);
Expand Down
81 changes: 81 additions & 0 deletions src/apps/chat/components/composer/actile/ActilePopup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding>

{!!props.title && (
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
{/*<ListItemDecorator/>*/}
<Typography level='title-md'>
{props.title}
</Typography>
</Sheet>
)}

{!props.items.length && (
<ListItem variant='soft' color='primary'>
<Typography level='body-md'>
No matching command
</Typography>
</ListItem>
)}

{props.items.map((item, idx) => {
const labelBold = item.label.slice(0, props.activePrefixLength);
const labelNormal = item.label.slice(props.activePrefixLength);
return (
<ListItemButton
key={item.id}
variant={idx === props.activeItemIndex ? 'soft' : undefined}
onClick={() => props.onItemClick(item)}
>
{hasAnyIcon && (
<ListItemDecorator>
{item.Icon ? <item.Icon /> : null}
</ListItemDecorator>
)}
<Box>

<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography level='title-md' color='primary'>
<span style={{ fontWeight: 600, textDecoration: 'underline' }}>{labelBold}</span>{labelNormal}
</Typography>
{item.argument && <Typography level='body-sm'>
{item.argument}
</Typography>}
</Box>

{!!item.description && <Typography level='body-xs'>
{item.description}
</Typography>}
</Box>
</ListItemButton>
);
},
)}

{props.children}

</CloseableMenu>
);
}
21 changes: 21 additions & 0 deletions src/apps/chat/components/composer/actile/ActileProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<ActileItem[]>;
onItemSelect: (item: ActileItem) => void;
}
Original file line number Diff line number Diff line change
@@ -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);
},
};
23 changes: 23 additions & 0 deletions src/apps/chat/components/composer/actile/providerCommands.tsx
Original file line number Diff line number Diff line change
@@ -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,
});
118 changes: 118 additions & 0 deletions src/apps/chat/components/composer/actile/useActileManager.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>) => {

// state
const [popupOpen, setPopupOpen] = React.useState(false);
const [provider, setProvider] = React.useState<ActileProvider | null>(null);

const [items, setItems] = React.useState<ActileItem[]>([]);
const [activeSearchString, setActiveSearchString] = React.useState<string>('');
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(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<HTMLTextAreaElement>): 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 : (
<ActilePopup
anchorEl={anchorRef.current}
onClose={handleClose}
title={provider?.title}
items={activeItems}
activeItemIndex={activeItemIndex}
activePrefixLength={activeSearchString.length}
onItemClick={handlePopupItemClicked}
/>
);
}, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, provider?.title]);

return {
actileComponent,
actileInterceptKeydown,
};
};
Loading

0 comments on commit 76778c5

Please sign in to comment.