feat: AssistantChatForm ui-pattern and AssistantCommandsPopover ui-pattern (#19296)

* proto

* chore: added pattern for input

* migrated whole form over

* Added basics

* add commands

* Update index.tsx

* remove old code

* add Input

* add chat into studio

* init stories

* revert

* Update AIPolicyChat.tsx

* revert

* udpate

* Update AssistantCommandsPopover.tsx

* Update packages/ui-patterns/AssistantChat/AssistantChatForm.tsx

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>

* Update AssistantCommandsPopover.tsx

* Update AssistantCommandsPopover.tsx

---------

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
Jonathan Summers-MuirandAlaister Young authored and GitHub committed 2024-03-29 15:27:05 +08:00
1 parent 67e1b1b00b
commit 33df85e910
8 files changed
+535 -4

No files matched your search

@@ -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 (
<div className="w-80 h-80">
<Story />
</div>
)
},
],
// 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<typeof AssistantChatForm>
export const CommandsPopover: Story = {
render: function Render(args) {
const [commandsOpen, setCommandsOpen] = useState<boolean>(false)
const textAreaRef = createRef<HTMLTextAreaElement>()
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 (
<>
<AssistantCommandsPopover
open={commandsOpen}
setOpen={setCommandsOpen}
textAreaRef={textAreaRef}
value={value}
setValue={(e) => setValueState(e)}
suggestions={suggestions}
>
<AssistantChatForm
{...args}
textAreaRef={textAreaRef}
value={value}
loading={loading}
disabled={loading}
onValueChange={(e) => setValueState(e.target.value)}
commandsOpen={commandsOpen}
setCommandsOpen={setCommandsOpen}
onSubmit={async (event) => {
event.preventDefault()
handleSubmit(event)
}}
/>
</AssistantCommandsPopover>
<p className="text-xs mt-3 text-foreground-lighter">
Press <span className="bg-surface-300 px-[3px] py-[2px] border rounded">/</span> to open
commands
</p>
</>
)
},
args: {
// placeholder: 'Ask AI a question...',
},
}
@@ -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 (
<div className="w-80">
<Story />
</div>
)
},
],
// 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<typeof AssistantChatForm>
export const Primary: Story = {
render: function Render(args) {
const textAreaRef = createRef<HTMLTextAreaElement>()
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 (
<AssistantChatForm
{...args}
textAreaRef={textAreaRef}
icon={<Box strokeWidth={1.5} size={24} className="text-foreground-muted" />}
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...',
},
}
@@ -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<HTMLFormElement> {
/* The ref for the textarea */
textAreaRef: React.RefObject<HTMLTextAreaElement>
/* 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<HTMLTextAreaElement>) => 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<HTMLFormElement>['onSubmit']
}
const AssistantChatForm = React.forwardRef<HTMLFormElement, FormProps>(
(
{
loading = false,
disabled = false,
value = '',
textAreaRef,
commandsOpen = false,
icon = null,
onValueChange,
setCommandsOpen,
onSubmit,
...props
},
ref
) => {
const formRef = createRef<HTMLFormElement>()
const submitRef = createRef<HTMLButtonElement>()
/**
* 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<HTMLTextAreaElement>) => {
// 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 (
<form ref={formRef} className="relative" {...props} onSubmit={onSubmit}>
{icon && (
<div className={cn('absolute', 'top-2 left-2', 'ml-1 w-6 h-6 rounded-full bg-dbnew')}>
{icon}
</div>
)}
<TextArea
ref={textAreaRef}
autoFocus
rows={1}
disabled={disabled}
contentEditable
aria-expanded={false}
className={cn(
icon && 'pl-12',
'transition-all text-sm pr-10 rounded-[18px] resize-none box-border leading-6'
)}
placeholder={props.placeholder}
spellCheck={false}
value={value}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(event)}
onKeyDown={handleKeyDown}
/>
<div className="absolute right-1.5 top-1.5 flex gap-3 items-center">
{loading && (
<Loader2 size={22} className="animate-spin w-7 h-7 text-muted" strokeWidth={1} />
)}
<button
ref={submitRef}
type="submit"
className={cn(
'transition-all',
'flex items-center justify-center w-7 h-7 border border-control rounded-full mr-0.5 p-1.5 background-alternative',
!value ? 'text-muted opacity-50' : 'text-default opacity-100',
loading && 'hidden'
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.5 3V2.25H15V3V10C15 10.5523 14.5522 11 14 11H3.56062L5.53029 12.9697L6.06062 13.5L4.99996 14.5607L4.46963 14.0303L1.39641 10.9571C1.00588 10.5666 1.00588 9.93342 1.39641 9.54289L4.46963 6.46967L4.99996 5.93934L6.06062 7L5.53029 7.53033L3.56062 9.5H13.5V3Z"
fill="currentColor"
></path>
</svg>
</button>
</div>
</form>
)
}
)
AssistantChatForm.displayName = 'AssistantChatForm'
export { AssistantChatForm }
@@ -0,0 +1,221 @@
'use client'
import {
ComponentPropsWithoutRef,
ElementRef,
createRef,
forwardRef,
useEffect,
useRef,
} from 'react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from './../../ui/src/components/shadcn/ui/command'
import { Popover, PopoverAnchor, PopoverContent } from './../../ui/src/components/shadcn/ui/popover'
import { cn } from './../../ui/src/lib/utils/cn'
const AssistantCommandsPopover = forwardRef<
ElementRef<typeof Popover>,
ComponentPropsWithoutRef<typeof Popover> & {
/* The children to render - this is where the AssistantChatForm should be placed */
children: React.ReactNode
/* The ref for the textarea - used with the AssistantChatForm */
textAreaRef: React.RefObject<HTMLTextAreaElement>
/* The function to handle the value change */
setValue: (value: string) => void
/* The value of the textarea */
value: string
/* Whether the popover is open */
open: boolean
/* The function to handle the popover open state */
setOpen: (value: boolean) => void
/* The suggestions to display in the popover */
suggestions?: string[]
/* Whether an icon is being used in the AssistantChatForm */
usingIcon?: boolean
}
>(
(
{
children,
textAreaRef,
setValue,
value,
open,
setOpen,
suggestions,
usingIcon = false,
...props
},
ref
) => {
const popoverContentRef = createRef<HTMLDivElement>()
const commandWidth = popoverContentRef.current?.clientWidth
const targetInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
// Attach the event listener when the component mounts
if (textAreaRef.current) {
textAreaRef.current.addEventListener('keydown', handleKeyPress)
}
// Detach the event listener when the component unmounts
return () => {
if (textAreaRef.current) {
textAreaRef.current.removeEventListener('keydown', handleKeyPress)
}
// Detach the event listener when the component unmounts
}
}, []) // Empty dependency array means this effect runs once when the component mounts
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === '/' && !value) {
// Add your action here
setOpen(true)
if (textAreaRef) textAreaRef?.current?.focus()
} else if (event.key === 'Escape') {
// Add your action here
setOpen(false)
} else if (event.key === 'Backspace') {
// Add your action here
setOpen(false)
} else {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Enter') {
// Forward the event to the target input
if (targetInputRef.current) {
const keyboardEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
composed: true,
key: event.key,
code: event.code,
})
targetInputRef.current.dispatchEvent(keyboardEvent)
// Schedule focus on the original input using requestAnimationFrame
requestAnimationFrame(() => {
if (textAreaRef.current) {
textAreaRef.current.focus()
}
})
// Prevent the default behavior for ArrowUp, ArrowDown, and Enter
event.preventDefault()
}
}
}
}
const resultArray = value.split(/(\s+)/).filter(Boolean)
const commands = ['fix', 'improve', 'explain', 'help']
return (
<>
<Popover
open={open}
onOpenChange={() => {
setOpen(!open)
if (textAreaRef) textAreaRef?.current?.focus()
}}
{...props}
>
<PopoverAnchor className={cn('w-full relative', usingIcon && 'bg-control')}>
<>
<div
style={{
left: '0px',
top: '10px',
marginLeft: commandWidth
? `${(usingIcon ? 48 : 12) + commandWidth + 12}px`
: `${usingIcon ? 48 : 12}px`,
}}
className={cn('z-0 absolute flex items-center text-sm text-transparent')}
>
{resultArray.map((item, i) => (
<span
key={i}
className={cn(
item === '/fix' ||
item === '/improve' ||
item === '/explain' ||
item === '/help'
? 'bg-surface-300'
: ''
)}
>
{item === ' ' ? '\u00A0' : item}
</span>
))}
</div>
{children}
</>
</PopoverAnchor>
<PopoverContent
ref={popoverContentRef}
className="w-[420px] p-0"
align="start"
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
>
<Command>
<CommandInput
placeholder="Type a command or search..."
wrapperClassName="hidden"
value={value}
ref={targetInputRef}
tabIndex={-1}
autoFocus={false}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
{suggestions?.map((suggestion, idx) => (
<CommandItem
key={idx}
value={'/ ' + suggestion}
className="text-sm gap-0.5"
onSelect={() => {
setValue(`${suggestion}`)
// closing of the popover is handled by the keydown event in AssistantChatForm
}}
>
<span className="text-default">{suggestion}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Commands">
{commands.map((command, idx) => (
<CommandItem
key={idx}
className="text-sm gap-0.5"
onSelect={() => {
setValue(`/${command} `)
// closing of the popover is handled by the keydown event in AssistantChatForm
}}
>
<span className="text-brand">/</span>
<span className="text-default">{command}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</>
)
}
)
export { AssistantCommandsPopover }
+2
View File
@@ -16,3 +16,5 @@ export * from './InnerSideMenu'
export * from './ShimmeringLoader'
export * from './Dialogs/ConfirmDialog'
export * from './Dialogs/ConfirmationModal'
export * from './AssistantChat/AssistantChatForm'
export * from './AssistantChat/AssistantCommandsPopover'
+1
View File
@@ -120,6 +120,7 @@ export {
Popover as Popover_Shadcn_,
PopoverTrigger as PopoverTrigger_Shadcn_,
PopoverContent as PopoverContent_Shadcn_,
PopoverAnchor as PopoverAnchor_Shadcn_,
} from './src/components/shadcn/ui/popover'
export {
@@ -37,9 +37,11 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-overlay px-3" cmdk-input-wrapper="">
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
wrapperClassName?: string
}
>(({ className, wrapperClassName, ...props }, ref) => (
<div className={cn('flex items-center border-b px-3', wrapperClassName)} cmdk-input-wrapper="">
<Search className="h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
@@ -7,6 +7,7 @@ import styles from './popover.module.css'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
type PopoverContentProps = {
portal?: boolean
@@ -50,4 +51,4 @@ const PopoverContent = React.forwardRef<
)
PopoverContent.displayName = 'PopoverContent'
export { Popover, PopoverContent, PopoverTrigger }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }