mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
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:
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 }
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user