mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## Context We previously introduced a behaviour for the explorer home tab's chat form to run a SQL Query if the input is detected to be a SQL query. Adjusting it to shift that behaviour into a secondary action instead <img width="740" height="210" alt="image" src="https://github.com/user-attachments/assets/d4b1fec1-f38c-426f-8108-b50ead1a61ca" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * SQL statements entered in Explorer can be run directly with a dedicated “Run SQL” action. * Assistant forms support context-specific submit icons, labels, tooltips, and accessibility text. * **Bug Fixes** * Improved SQL detection for multi-statement queries. * Prevented mixed SQL and conversational text from being treated as executable SQL. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
223 lines
7.7 KiB
TypeScript
223 lines
7.7 KiB
TypeScript
import { useBreakpoint } from 'common'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { ArrowUp, Loader2, Square } from 'lucide-react'
|
|
import { ChangeEvent, FormEvent, forwardRef, KeyboardEvent, memo, ReactNode, useRef } from 'react'
|
|
import { Button, cn, ExpandingTextArea } from 'ui'
|
|
|
|
import { ButtonTooltip } from '../ButtonTooltip'
|
|
import { formatAttachedSnippets } from './AIAssistant.utils'
|
|
import { ModelSelector } from './ModelSelector'
|
|
import { SnippetRow } from './SnippetRow'
|
|
import type { AssistantModelId } from '@/lib/ai/model.utils'
|
|
import { type SqlSnippet } from '@/state/ai-assistant-state'
|
|
|
|
export interface FormProps {
|
|
/* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */
|
|
textAreaRef?: React.RefObject<HTMLTextAreaElement | null>
|
|
/* 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
|
|
/**
|
|
* If true, include SQL snippets in the message sent to onSubmit
|
|
*/
|
|
includeSnippetsInMessage?: boolean
|
|
/**
|
|
* The function to handle the form submission
|
|
*/
|
|
onSubmit: (message: string) => void
|
|
/**
|
|
* The function to handle stopping the stream
|
|
*/
|
|
onStop?: () => void
|
|
/* The placeholder of the textarea */
|
|
placeholder?: string
|
|
/* SQL snippets to display above the form - can be strings or objects with label and content */
|
|
sqlSnippets?: SqlSnippet[]
|
|
/* Function to handle removing a SQL snippet */
|
|
onRemoveSnippet?: (index: number) => void
|
|
/* Additional class name for the snippets container */
|
|
snippetsClassName?: string
|
|
/* Additional class name for the form wrapper */
|
|
className?: string
|
|
/* If currently editing an existing message */
|
|
isEditing?: boolean
|
|
/* The currently selected AI model */
|
|
selectedModel?: AssistantModelId
|
|
/* Callback when a model is chosen */
|
|
onSelectModel?: (model: AssistantModelId) => void
|
|
/* Icon for the submit button when not loading, defaults to an up arrow */
|
|
submitIcon?: ReactNode
|
|
/* Label for the submit button when not loading, used for both aria-label and tooltip text, defaults to "Send message" */
|
|
submitLabel?: string
|
|
/**
|
|
* An additional action shown beside the main submit button, transitioned in/out with
|
|
* framer-motion. Pass undefined to hide it (e.g. when the input doesn't match its condition).
|
|
*/
|
|
secondaryAction?: {
|
|
label: string
|
|
onClick: () => void
|
|
}
|
|
}
|
|
|
|
const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
|
|
(
|
|
{
|
|
loading = false,
|
|
disabled = false,
|
|
value = '',
|
|
textAreaRef,
|
|
onValueChange,
|
|
onSubmit,
|
|
onStop,
|
|
placeholder,
|
|
sqlSnippets,
|
|
onRemoveSnippet,
|
|
snippetsClassName,
|
|
includeSnippetsInMessage = false,
|
|
className,
|
|
isEditing = false,
|
|
selectedModel,
|
|
onSelectModel,
|
|
submitIcon = <ArrowUp />,
|
|
submitLabel = 'Send message',
|
|
secondaryAction,
|
|
...props
|
|
},
|
|
_ref
|
|
) => {
|
|
const formRef = useRef<HTMLFormElement>(null)
|
|
const isMobile = useBreakpoint('md')
|
|
|
|
const handleSubmit = (event?: FormEvent<HTMLFormElement>) => {
|
|
if (event) event.preventDefault()
|
|
if (disabled || !value || (loading && !isEditing)) return
|
|
|
|
let finalMessage = value
|
|
if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) {
|
|
finalMessage = [value, formatAttachedSnippets(sqlSnippets)].filter(Boolean).join('\n\n')
|
|
}
|
|
|
|
onSubmit(finalMessage)
|
|
}
|
|
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
handleSubmit()
|
|
} else if (event.key === 'Escape') {
|
|
event.currentTarget.blur()
|
|
}
|
|
}
|
|
|
|
const canSubmit = !disabled && !loading && !!value
|
|
const showModelSelector = selectedModel !== undefined && onSelectModel !== undefined
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<form
|
|
id="assistant-chat"
|
|
ref={formRef}
|
|
{...props}
|
|
onSubmit={handleSubmit}
|
|
className={cn('relative rounded-lg', className)}
|
|
>
|
|
{sqlSnippets && sqlSnippets.length > 0 && (
|
|
<SnippetRow
|
|
snippets={sqlSnippets}
|
|
onRemoveSnippet={onRemoveSnippet}
|
|
className="absolute top-0 left-0 right-0 px-1.5 py-1.5"
|
|
/>
|
|
)}
|
|
<ExpandingTextArea
|
|
autoFocus={!isMobile}
|
|
ref={textAreaRef}
|
|
disabled={disabled}
|
|
className={cn(
|
|
'text-base md:text-sm pr-10 pb-9 max-h-64 rounded-lg',
|
|
sqlSnippets && sqlSnippets.length > 0 && 'pt-10'
|
|
)}
|
|
placeholder={placeholder}
|
|
spellCheck={false}
|
|
rows={3}
|
|
value={value}
|
|
onChange={(event) => onValueChange(event)}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
'absolute inset-x-2 bottom-2 flex items-center pointer-events-none',
|
|
showModelSelector ? 'justify-between' : 'justify-end'
|
|
)}
|
|
>
|
|
{showModelSelector && (
|
|
<div className="pointer-events-auto">
|
|
<ModelSelector selectedModel={selectedModel} onSelectModel={onSelectModel} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-x-1.5 items-center pointer-events-auto">
|
|
<AnimatePresence>
|
|
{!loading && secondaryAction && (
|
|
<motion.div
|
|
key="secondary-action"
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
aria-label={secondaryAction.label}
|
|
onClick={secondaryAction.onClick}
|
|
className="h-7 rounded-full"
|
|
>
|
|
{secondaryAction.label}
|
|
</Button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
{loading ? (
|
|
onStop ? (
|
|
<ButtonTooltip
|
|
variant="outline"
|
|
aria-label="Stop response"
|
|
icon={<Square fill="currentColor" className="scale-75" />}
|
|
onClick={onStop}
|
|
className="w-7 h-7 rounded-full p-0 text-center flex items-center justify-center"
|
|
tooltip={{ content: { side: 'top', text: 'Stop response' } }}
|
|
/>
|
|
) : (
|
|
<Loader2 size={22} className="animate-spin size-7 text-muted" strokeWidth={1} />
|
|
)
|
|
) : (
|
|
<ButtonTooltip
|
|
type="submit"
|
|
variant={canSubmit ? 'primary' : 'default'}
|
|
aria-label={submitLabel}
|
|
icon={submitIcon}
|
|
disabled={!canSubmit}
|
|
className={cn(
|
|
'w-7 h-7 rounded-full p-0 text-center flex items-center justify-center',
|
|
!canSubmit ? 'opacity-50' : 'opacity-100'
|
|
)}
|
|
tooltip={{ content: { side: 'top', text: submitLabel } }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
AssistantChatFormComponent.displayName = 'AssistantChatFormComponent'
|
|
|
|
export const AssistantChatForm = memo(AssistantChatFormComponent)
|