Skip to content

Commit

Permalink
feat(toolkit): New Command Dialog (#722)
Browse files Browse the repository at this point in the history
* feat(toolkit): improved command menu

* feat(toolkit): improved command menu

* feat(toolkit): improved command menu

* filter out global commands

* add custom action

* improve dialog styling

* add close on select option

* new command menu
  • Loading branch information
knajjars authored Aug 21, 2024
1 parent 031e240 commit 99de6a9
Show file tree
Hide file tree
Showing 13 changed files with 574 additions and 247 deletions.
2 changes: 1 addition & 1 deletion src/interfaces/assistants_web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,4 @@
"storybook": "^8.2.6",
"vitest": "^2.0.5"
}
}
}
20 changes: 20 additions & 0 deletions src/interfaces/assistants_web/src/components/Agents/AgentLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AgentPublic } from '@/cohere-client';
import { CoralLogo, Text } from '@/components/Shared';
import { useBrandedColors } from '@/hooks/brandedColors';
import { cn } from '@/utils';

export const AgentLogo = ({ agent }: { agent: AgentPublic }) => {
const isBaseAgent = !agent.id;
const { bg, contrastText, contrastFill } = useBrandedColors(agent.id);

return (
<div className={cn('flex size-5 flex-shrink-0 items-center justify-center rounded', bg)}>
{isBaseAgent && <CoralLogo className={cn(contrastFill, 'size-3')} />}
{!isBaseAgent && (
<Text className={cn('uppercase', contrastText)} styleAs="p-sm">
{agent.name[0]}
</Text>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,56 +1,87 @@
import { ComboboxOption } from '@headlessui/react';
import { useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';

import { type QuickAction } from '@/components/Shared/HotKeys/domain';
import { Text } from '@/components/Shared/Text';
import { useOS } from '@/hooks/os';
import { cn } from '@/utils';

interface Props extends QuickAction {
isOpen: boolean;
}

export const CommandAction: React.FC<Props> = ({ name, commands, action, isOpen }) => {
export const CommandAction: React.FC<Props> = ({ label, name, commands, action, isOpen }) => {
const os = useOS();

useHotkeys(
commands,
(e) => {
if (!isOpen) return;
e.preventDefault();
action();
action?.();
},
{
enableOnFormTags: true,
},
[isOpen, action]
);

const formatCommand = () => {
if (commands.length === 0) return '';
const formattedCommands = useMemo(() => {
if (commands.length === 0) return [];
const [command] = commands;
return command
.split('+')
.map((key) => {
if (key === 'ctrl') return os === 'macOS' ? '⌘' : 'Ctrl';
if (key === 'meta') return os === 'macOS' ? '⌘' : 'Win';
if (key === 'alt') return os === 'macOS' ? 'Option' : 'Alt';
if (key === 'shift') return 'Shift';
if (key === 'backspace') return 'Backspace';
return key.toUpperCase();
})
.join(' + ');
};
return command.split('+').map((key) => {
if (key === 'meta') return os === 'macOS' ? '⌘' : 'win';
if (key === 'alt') return os === 'macOS' ? '⌥' : 'alt';
if (key === 'shift') return 'shift';
if (key === 'backspace') return 'backspace';
if (key.length === 1) return key.toUpperCase();
return key;
});
}, [commands, os]);

return (
<>
<ComboboxOption
key={name}
value={action}
className="flex select-none items-center p-4 data-[focus]:bg-green-500 data-[focus]:text-white data-[focus]:dark:bg-volcanic-400"
value={name}
className={cn(
'flex select-none items-center px-6 py-4',
'data-[focus]:bg-volcanic-900 data-[focus]:dark:bg-volcanic-300'
)}
>
<Text className="mx-3 flex w-full items-center justify-between">
<Text className="flex-auto truncate">{name}</Text>
<kbd className="font-code">{formatCommand()}</kbd>
</Text>
{({ focus }) => (
<Text className="flex w-full items-center justify-between">
<Text
as="span"
className={cn('flex-auto truncate', 'text-volcanic-500 dark:text-volcanic-800', {
'text-volcanic-200 dark:text-marble-1000': focus,
})}
>
{label ?? name}
</Text>
<span className="flex gap-x-1">
{formattedCommands.map((key) => (
<Text
as="kbd"
key={key}
styleAs="p-sm"
className={cn(
'rounded bg-volcanic-800 px-1.5 py-1',
'bg-volcanic-900 text-volcanic-500 dark:bg-volcanic-100 dark:text-volcanic-800',

{
'bg-volcanic-800 text-volcanic-200 dark:bg-volcanic-60 dark:text-marble-1000':
focus,
}
)}
>
{key}
</Text>
))}
</span>
</Text>
)}
</ComboboxOption>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,29 @@ type Props = {
};

const CommandActionGroup: React.FC<Props> = ({ isOpen, options = [] }) => {
return options.map((action) => {
return (
<section key={action.group}>
<Text styleAs="p-sm" className="mx-3 p-4 uppercase dark:text-marble-800">
{action.group}
</Text>
{action.quickActions.map((quickAction) => (
<CommandAction key={quickAction.name} isOpen={isOpen} {...quickAction} />
))}
</section>
);
});
return (
<div className="flex flex-col gap-y-4">
{options.map((action, i) => {
return (
<section key={i} className="flex flex-col">
{action.group && (
<Text
styleAs="p-sm"
className="px-6 pb-4 font-medium uppercase text-volcanic-300 dark:text-volcanic-500"
>
{action.group}
</Text>
)}
{action.quickActions
.filter((quickAction) => quickAction.displayInDialog !== false)
.map((quickAction) => (
<CommandAction key={quickAction.name} isOpen={isOpen} {...quickAction} />
))}
</section>
);
})}
</div>
);
};

export default CommandActionGroup;
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
'use client';

import { HotKeysProvider } from '@/components/Shared/HotKeys/HotKeysProvider';
import { useChatHotKeys, useLayoutHotKeys } from '@/hooks/actions';
import {
useAssistantHotKeys,
useConversationHotKeys,
useSettingsHotKeys,
useViewHotKeys,
} from '@/hooks/actions';

export const HotKeys: React.FC = () => {
const chatHotKeys = useChatHotKeys();
const layoutHotKeys = useLayoutHotKeys();
const hotKeys = [...chatHotKeys, ...layoutHotKeys];
const conversationHotKeys = useConversationHotKeys();
const viewHotKeys = useViewHotKeys();
const settingsHotKeys = useSettingsHotKeys();
const assistantHotKeys = useAssistantHotKeys({ displayRecentAgentsInDialog: false });
const hotKeys = [...conversationHotKeys, ...viewHotKeys, ...assistantHotKeys, ...settingsHotKeys];

return <HotKeysProvider hotKeys={hotKeys} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@

import {
Combobox,
ComboboxInput,
ComboboxOptions,
Dialog,
DialogPanel,
Transition,
TransitionChild,
} from '@headlessui/react';
import { Fragment, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';

import CommandActionGroup from '@/components/Shared/HotKeys/CommandActionGroup';
import { HotKeysDialogInput } from '@/components/Shared/HotKeys/HotKeysDialogInput';
import { type HotKeyGroupOption } from '@/components/Shared/HotKeys/domain';
import { Input } from '@/components/Shared/Input';
import { Text } from '@/components/Shared/Text';
import { cn } from '@/utils';

type Props = {
isOpen: boolean;
Expand All @@ -24,6 +24,14 @@ type Props = {

export const HotKeysDialog: React.FC<Props> = ({ isOpen, close, options = [] }) => {
const [query, setQuery] = useState('');
const [customView, setCustomView] = useState<string | null>(null);

const View = useMemo(() => {
const option = options.find((option) =>
option.quickActions.some((action) => action.name === customView)
);
return option?.quickActions.find((action) => action.name === customView)?.customView;
}, [customView, options]);

const filteredCustomActions = useMemo(() => {
if (query === '') return [];
Expand All @@ -44,11 +52,31 @@ export const HotKeysDialog: React.FC<Props> = ({ isOpen, close, options = [] })
}, [query, options]);

const handleOnChange = (command: string | null) => {
if (command !== null) {
close();
const hotkey = options
.flatMap((option) => option.quickActions)
.find((option) => option.name === command);

if (hotkey) {
if (hotkey.closeDialogOnRun) {
close();
}
hotkey.action?.();
if (!!hotkey.customView) {
setCustomView(hotkey.name);
}
}
};

useEffect(() => {
if (!isOpen) {
// Delay to prevent flickering
setTimeout(() => {
setCustomView(null);
setQuery('');
}, 300);
}
}, [isOpen]);

return (
<Transition show={isOpen} as={Fragment} appear>
<Dialog as="div" className="relative z-modal" onClose={close}>
Expand All @@ -61,10 +89,9 @@ export const HotKeysDialog: React.FC<Props> = ({ isOpen, close, options = [] })
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="transition-o`pacity fixed inset-0 bg-volcanic-300/20 backdrop-blur-sm" />
<div className="fixed inset-0 bg-[#B3B3B3]/60 transition-opacity dark:bg-[#1C1C1C]/80" />
</TransitionChild>

<div className="fixed inset-0 flex items-center justify-center overflow-y-auto p-4">
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
Expand All @@ -74,32 +101,35 @@ export const HotKeysDialog: React.FC<Props> = ({ isOpen, close, options = [] })
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-90"
>
<DialogPanel className="relative flex w-full flex-col rounded-lg bg-marble-1000 dark:bg-volcanic-200 md:w-modal">
<Combobox as="div" onChange={handleOnChange}>
<div className="mb-4 px-6 pt-6">
<ComboboxInput
as={Input}
placeholder="Type a command or search..."
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
close();
}
}}
autoFocus
className="border-none bg-transparent focus:bg-transparent dark:bg-transparent dark:focus:bg-transparent"
/>
<hr className="border-t dark:border-volcanic-700" />
</div>
{filteredCustomActions.length > 0 && (
<ComboboxOptions className="my-4 max-h-72 space-y-6 overflow-y-auto" static>
<CommandActionGroup isOpen={isOpen} options={filteredCustomActions} />
</ComboboxOptions>
<DialogPanel>
<Combobox
as="div"
onChange={handleOnChange}
immediate
className={cn(
'relative flex max-h-[480px] w-full flex-col overflow-y-hidden rounded-lg bg-volcanic-950 transition-all duration-300 dark:bg-volcanic-200 md:w-modal'
)}
{query === '' && <CommandActionGroup isOpen={isOpen} options={options} />}
{query !== '' && filteredCustomActions.length === 0 && (
<Text className="py-14 text-center">No results for &quot;{query}&quot;</Text>
>
{View ? (
<View close={close} onBack={() => setCustomView(null)} />
) : (
<>
<HotKeysDialogInput
value={query}
setValue={setQuery}
close={close}
placeholder="Find a command"
/>
<ComboboxOptions className="flex flex-col gap-y-6 overflow-y-auto pb-3" static>
{filteredCustomActions.length > 0 && (
<CommandActionGroup isOpen={isOpen} options={filteredCustomActions} />
)}
{query === '' && <CommandActionGroup isOpen={isOpen} options={options} />}
{query !== '' && filteredCustomActions.length === 0 && (
<Text className="p-6">No results for &quot;{query}&quot;</Text>
)}
</ComboboxOptions>
</>
)}
</Combobox>
</DialogPanel>
Expand Down
Loading

0 comments on commit 99de6a9

Please sign in to comment.