diff --git a/packages/ui-patterns/AssistantChat/AssistantChatCommands.stories.tsx b/packages/ui-patterns/AssistantChat/AssistantChatCommands.stories.tsx new file mode 100644 index 00000000000..115209b17c4 --- /dev/null +++ b/packages/ui-patterns/AssistantChat/AssistantChatCommands.stories.tsx @@ -0,0 +1,90 @@ +import { StoryObj } from '@storybook/react' +import { AssistantChatForm } from './AssistantChatForm' +import { AssistantCommandsPopover } from './AssistantCommandsPopover' +import { Box, User } from 'lucide-react' +import { createRef, useRef, useState } from 'react' + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +export default { + title: 'AI/Assistant Chat', + component: AssistantChatForm, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'centered', + }, + decorators: [ + (Story: any) => { + return ( +
+ +
+ ) + }, + ], + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/api/argtypes + // argTypes: {}, +} + +// export default meta + +type Story = StoryObj + +export const CommandsPopover: Story = { + render: function Render(args) { + const [commandsOpen, setCommandsOpen] = useState(false) + const textAreaRef = createRef() + const [value, setValueState] = useState('') + const [loading, setLoading] = useState(false) + + function handleSubmit(event: React.FormEvent) { + // set loading for 3 seconds and then reset + setLoading(true) + setTimeout(() => { + setLoading(false) + }, 1500) + } + + const suggestions = [ + 'Add policy for org Inserted User Access', + 'Add policy for User-Specific Todo Access', + 'Add policy for Org Update Restriction', + ] + + return ( + <> + setValueState(e)} + suggestions={suggestions} + > + setValueState(e.target.value)} + commandsOpen={commandsOpen} + setCommandsOpen={setCommandsOpen} + onSubmit={async (event) => { + event.preventDefault() + handleSubmit(event) + }} + /> + +

+ Press / to open + commands +

+ + ) + }, + args: { + // placeholder: 'Ask AI a question...', + }, +} diff --git a/packages/ui-patterns/AssistantChat/AssistantChatForm.stories.tsx b/packages/ui-patterns/AssistantChat/AssistantChatForm.stories.tsx new file mode 100644 index 00000000000..cb643aeaac2 --- /dev/null +++ b/packages/ui-patterns/AssistantChat/AssistantChatForm.stories.tsx @@ -0,0 +1,66 @@ +import { StoryObj } from '@storybook/react' +import { AssistantChatForm } from './AssistantChatForm' +import { Box, User } from 'lucide-react' +import { createRef, useRef, useState } from 'react' + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +export default { + title: 'AI/Assistant Chat', + component: AssistantChatForm, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'centered', + }, + decorators: [ + (Story: any) => { + return ( +
+ +
+ ) + }, + ], + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/api/argtypes + // argTypes: {}, +} + +// export default meta + +type Story = StoryObj + +export const Primary: Story = { + render: function Render(args) { + const textAreaRef = createRef() + const [value, setValueState] = useState('') + const [loading, setLoading] = useState(false) + + function handleSubmit(event: React.FormEvent) { + // set loading for 3 seconds and then reset + setLoading(true) + setTimeout(() => { + setLoading(false) + }, 1500) + } + + return ( + } + value={value} + loading={loading} + disabled={loading} + onValueChange={(e) => setValueState(e.target.value)} + onSubmit={async (event) => { + event.preventDefault() + handleSubmit(event) + }} + /> + ) + }, + args: { + // placeholder: 'Ask AI a question...', + }, +} diff --git a/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx b/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx new file mode 100644 index 00000000000..4f4190aa723 --- /dev/null +++ b/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx @@ -0,0 +1,148 @@ +import { Loader2 } from 'lucide-react' +import React, { ChangeEvent, createRef, useEffect } from 'react' +import { TextArea } from 'ui/src/components/shadcn/ui/text-area' +import { cn } from 'ui/src/lib/utils' + +export interface FormProps extends React.FormHTMLAttributes { + /* The ref for the textarea */ + textAreaRef: React.RefObject + /* The loading state of the form */ + loading: boolean + /* The disabled state of the form */ + disabled?: boolean + /* The value of the textarea */ + value?: string + /* The function to handle the value change */ + onValueChange: (value: ChangeEvent) => void + /* Used to stop onSubmit event when Command popover is open. Use with AssistantCommandsPopover */ + commandsOpen?: boolean + /* Used to close Command popover when onSubmit event happens. Use with AssistantCommandsPopover */ + setCommandsOpen?: (value: boolean) => void + /* The icon to display on the left of the textarea */ + icon?: React.ReactNode + /* The function to handle the form submission */ + onSubmit: React.FormHTMLAttributes['onSubmit'] +} + +const AssistantChatForm = React.forwardRef( + ( + { + loading = false, + disabled = false, + value = '', + textAreaRef, + commandsOpen = false, + icon = null, + onValueChange, + setCommandsOpen, + onSubmit, + ...props + }, + ref + ) => { + const formRef = createRef() + const submitRef = createRef() + + /** + * This effect is used to resize the textarea based on the content + */ + useEffect(() => { + if (textAreaRef) { + if (!value && textAreaRef && textAreaRef.current) { + textAreaRef.current.style.height = '40px' + } else if (textAreaRef && textAreaRef.current) { + const newHeight = textAreaRef.current.scrollHeight + 'px' + textAreaRef.current.style.height = newHeight + } + } + }, [value, textAreaRef]) + + /** + * This effect is used to focus the textarea when the component mounts + */ + useEffect(() => { + textAreaRef?.current?.focus() + }, [value, textAreaRef]) + + /** + * This function is used to handle the "Enter" key press + */ + const handleKeyDown = (event: React.KeyboardEvent) => { + // Check if the pressed key is "Enter" (key code 13) without the "Shift" key + // also checks if the commands popover is open + if (event.key === 'Enter' && !event.shiftKey && !commandsOpen) { + event.preventDefault() + if (submitRef.current) { + submitRef.current.click() + } + } + + // handles closing the commands popover if open + if (event.key === 'Enter' && commandsOpen) { + if (setCommandsOpen) setCommandsOpen(false) + } + } + + return ( +
+ {icon && ( +
+ {icon} +
+ )} +