Updates Assistant snippet handling (#36948)

* ui refinements

* rename chat

* copy

* prose message styles

* add icon back

* fix message save

* simplify empty state

* update suggestions

* pass through props

* button styles

* onboarding icons

* name button

* remove results

* current chat name

* use type

* fix down arrow

* re-add chat name

* refactor assistant form

* remove import

* snippet style

* move prompts out

* extract snippets
This commit is contained in:
Saxon Fletcher
2025-07-09 18:00:41 +10:00
committed by GitHub
parent c0b3a86052
commit b49173513e
5 changed files with 267 additions and 118 deletions

View File

@@ -0,0 +1,42 @@
export const defaultPrompts = [
{
title: 'Create a back-end',
prompt:
'Create a messaging app with users, messages, and an edge function that uses OpenAI to summarize message threads.',
},
{
title: 'Health check',
prompt: 'Can you check if my database and edge functions are healthy?',
},
{
title: 'Query your data',
prompt: 'Give me a list of new users from the auth.users table who signed up in the past week',
},
{
title: 'Set up RLS policies',
prompt: 'Create RLS policies to ensure users can only access their own data',
},
{
title: 'Create a function',
prompt: 'Create an edge function that summarises the contents of a table row using OpenAI',
},
{
title: 'Generate sample data',
prompt: 'Generate sample data for a blog with users, posts, and comments tables',
},
]
export const codeSnippetPrompts = [
{
title: 'Explain code',
prompt: 'Explain what this code does and how it works',
},
{
title: 'Improve code',
prompt: 'How can I improve this code for better performance and readability?',
},
{
title: 'Debug issues',
prompt: 'Help me debug any potential issues with this code',
},
]

View File

@@ -22,14 +22,14 @@ import uuidv4 from 'lib/uuid'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { AiIconAnimation, Button, cn } from 'ui'
import { Admonition, AssistantChatForm, GenericSkeletonLoader } from 'ui-patterns'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import { ButtonTooltip } from '../ButtonTooltip'
import { ErrorBoundary } from '../ErrorBoundary'
import { onErrorChat } from './AIAssistant.utils'
import { AIAssistantChatSelector } from './AIAssistantChatSelector'
import { AIOnboarding } from './AIOnboarding'
import { AIOptInModal } from './AIOptInModal'
import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
import { AssistantChatForm } from './AssistantChatForm'
import { Message } from './Message'
import { useAutoScroll } from './hooks'
import type { AssistantMessageType } from 'state/ai-assistant-state'
@@ -246,34 +246,20 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const hasMessages = chatMessages.length > 0
const isShowingOnboarding = !hasMessages && isApiKeySet
const sendMessageToAssistant = (content: string) => {
let finalContent = content
// Handle SQL snippets based on opt-in level
if (aiOptInLevel !== 'disabled') {
const sqlSnippetsString =
snap.sqlSnippets?.map((snippet: string) => '```sql\n' + snippet + '\n```').join('\n') || ''
finalContent = [content, sqlSnippetsString].filter(Boolean).join('\n\n')
} else {
snap.setSqlSnippets([])
}
const sendMessageToAssistant = (finalContent: string) => {
const payload = {
role: 'user',
createdAt: new Date(),
content: finalContent,
id: uuidv4(),
} as MessageType
snap.clearSqlSnippets()
// Store the user message in the ref before appending
lastUserMessageRef.current = payload
append(payload)
setValue('')
if (content.includes('Help me to debug')) {
if (finalContent.includes('Help me to debug')) {
sendEvent({
action: 'assistant_debug_submitted',
groups: {
@@ -552,23 +538,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
{!isShowingOnboarding && (
<div className="px-3 pb-3 z-20 relative">
{snap.sqlSnippets && snap.sqlSnippets.length > 0 && (
<div className="mb-0 mx-4">
{snap.sqlSnippets.map((snippet: string, index: number) => (
<CollapsibleCodeBlock
key={index}
hideLineNumbers
value={snippet}
onRemove={() => {
const newSnippets = [...(snap.sqlSnippets ?? [])]
newSnippets.splice(index, 1)
snap.setSqlSnippets(newSnippets)
}}
className="text-xs rounded-b-none border-b-0"
/>
))}
</div>
)}
{disablePrompts && (
<Admonition
showIcon={false}
@@ -595,7 +564,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
<AssistantChatForm
textAreaRef={inputRef}
className={cn(
'z-20 [&>textarea]:text-base [&>textarea]:md:text-sm [&>textarea]:border-1 [&>textarea]:rounded-md [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0'
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
)}
loading={isChatLoading}
disabled={!isApiKeySet || disablePrompts || isChatLoading}
@@ -607,13 +576,18 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
: 'Chat to Postgres...'
}
value={value}
autoFocus
onValueChange={(e) => setValue(e.target.value)}
onSubmit={(event) => {
event.preventDefault()
sendMessageToAssistant(value)
onSubmit={(finalMessage) => {
sendMessageToAssistant(finalMessage)
scrollToEnd()
}}
sqlSnippets={snap.sqlSnippets as string[] | undefined}
onRemoveSnippet={(index) => {
const newSnippets = [...(snap.sqlSnippets ?? [])]
newSnippets.splice(index, 1)
snap.setSqlSnippets(newSnippets)
}}
includeSnippetsInMessage={aiOptInLevel !== 'disabled'}
/>
</div>
)}

View File

@@ -1,10 +1,10 @@
import { motion } from 'framer-motion'
import { Code, FileText, Heart, MessageCircleMore, Shield, WandSparkles } from 'lucide-react'
import { FileText } from 'lucide-react'
import { useRef } from 'react'
import { Button, cn } from 'ui'
import { AssistantChatForm } from 'ui-patterns'
import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
import { AssistantChatForm } from './AssistantChatForm'
import { codeSnippetPrompts, defaultPrompts } from './AIAssistant.prompts'
interface AIOnboardingProps {
onMessageSend: (message: string) => void
@@ -28,60 +28,6 @@ export const AIOnboarding = ({
}: AIOnboardingProps) => {
const inputRef = useRef<HTMLTextAreaElement>(null)
const defaultPrompts = [
{
title: 'Create a back-end',
prompt:
'Create a messaging app with users, messages, and an edge function that uses OpenAI to summarize message threads.',
icon: <WandSparkles strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
{
title: 'Health check',
prompt: 'Can you check if my database and edge functions are healthy?',
icon: <Heart strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
{
title: 'Query your data',
prompt:
'Give me a list of new users from the auth.users table who signed up in the past week',
icon: <FileText strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
{
title: 'Set up RLS policies',
prompt: 'Create RLS policies to ensure users can only access their own data',
icon: <Shield strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
{
title: 'Create a function',
prompt: 'Create an edge function that summarises the contents of a table row using OpenAI',
icon: <Code strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
{
title: 'Generate sample data',
prompt: 'Generate sample data for a blog with users, posts, and comments tables',
icon: <FileText strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
]
const codeSnippetPrompts = [
{
title: 'Explain code',
prompt: 'Explain what this code does and how it works',
icon: <FileText strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
{
title: 'Improve code',
prompt: 'How can I improve this code for better performance and readability?',
icon: <WandSparkles strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
{
title: 'Debug issues',
prompt: 'Help me debug any potential issues with this code',
icon: <MessageCircleMore strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
},
]
// Use suggestions if available, otherwise use code-specific prompts if snippets exist, or default prompts
const prompts = suggestions?.prompts
? suggestions.prompts.map((suggestion) => ({
title: suggestion.label,
@@ -105,37 +51,26 @@ export const AIOnboarding = ({
<h2 className="text-2xl mb-6">How can I assist you?</h2>
<div className="w-full mb-6">
{sqlSnippets && sqlSnippets.length > 0 && (
<div className="mx-4">
{sqlSnippets.map((snippet: string, index: number) => (
<CollapsibleCodeBlock
key={index}
hideLineNumbers
value={snippet}
onRemove={() => onRemoveSnippet?.(index)}
className="text-xs rounded-b-none border-b-0 text-left"
/>
))}
</div>
)}
<AssistantChatForm
textAreaRef={inputRef}
className={cn(
'z-20 [&>textarea]:text-base [&>textarea]:md:text-sm [&>textarea]:border-1 [&>textarea]:rounded-md [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0'
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
)}
loading={false}
disabled={false}
placeholder="Ask me anything..."
value={value}
onValueChange={(e) => onValueChange(e.target.value)}
onSubmit={(event) => {
event.preventDefault()
if (value.trim()) {
onMessageSend(value)
onSubmit={(finalMessage) => {
if (finalMessage.trim()) {
onMessageSend(finalMessage)
onValueChange('')
}
}}
autoFocus
sqlSnippets={sqlSnippets}
onRemoveSnippet={onRemoveSnippet}
snippetsClassName="text-left"
includeSnippetsInMessage={true}
/>
</div>
@@ -149,7 +84,6 @@ export const AIOnboarding = ({
>
<Button
size="small"
icon={item.icon}
type="outline"
className="text-xs rounded-full !h-auto py-1 px-2 text-foreground-light"
onClick={() => {

View File

@@ -0,0 +1,142 @@
'use client'
import { useBreakpoint } from 'common'
import { ArrowUp, Loader2 } from 'lucide-react'
import React, { ChangeEvent, memo, useRef } from 'react'
import { Button, ExpandingTextArea } from 'ui'
import { cn } from 'ui/src/lib/utils'
import { SnippetRow } from './SnippetRow'
export interface FormProps {
/* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */
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
/**
* 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 placeholder of the textarea */
placeholder?: string
/* SQL snippets to display above the form */
sqlSnippets?: string[]
/* 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
}
const AssistantChatFormComponent = React.forwardRef<HTMLFormElement, FormProps>(
(
{
loading = false,
disabled = false,
value = '',
textAreaRef,
onValueChange,
onSubmit,
placeholder,
sqlSnippets,
onRemoveSnippet,
snippetsClassName,
includeSnippetsInMessage = false,
className,
...props
},
ref
) => {
const formRef = useRef<HTMLFormElement>(null)
const isMobile = useBreakpoint('md')
const handleSubmit = (event?: React.FormEvent<HTMLFormElement>) => {
if (event) event.preventDefault()
if (!value || loading) return
let finalMessage = value
if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) {
const sqlSnippetsString = sqlSnippets
.map((snippet: string) => '```sql\n' + snippet + '\n```')
.join('\n')
finalMessage = [value, sqlSnippetsString].filter(Boolean).join('\n\n')
}
onSubmit(finalMessage)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}
const canSubmit = !loading && !!value
return (
<div className="w-full">
<form
id="assistant-chat"
ref={formRef}
{...props}
onSubmit={handleSubmit}
className={cn('relative overflow-hidden', 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
ref={textAreaRef}
autoFocus={isMobile}
disabled={disabled}
className={cn(
'text-sm pr-10 max-h-64',
sqlSnippets && sqlSnippets.length > 0 && 'pt-10'
)}
placeholder={placeholder}
spellCheck={false}
rows={3}
value={value}
onChange={(event) => onValueChange(event)}
onKeyDown={handleKeyDown}
/>
<div className="absolute right-1.5 bottom-1.5 flex gap-3 items-center">
{loading && (
<Loader2 size={22} className="animate-spin w-7 h-7 text-muted" strokeWidth={1} />
)}
<Button
htmlType="submit"
aria-label="Send message"
icon={<ArrowUp />}
disabled={!canSubmit}
className={cn(
'w-7 h-7 rounded-full p-0 text-center flex items-center justify-center',
!canSubmit ? 'text-muted opacity-50' : 'text-default opacity-100',
loading && 'hidden'
)}
/>
</div>
</form>
</div>
)
}
)
AssistantChatFormComponent.displayName = 'AssistantChatFormComponent'
export const AssistantChatForm = memo(AssistantChatFormComponent)

View File

@@ -0,0 +1,57 @@
import React from 'react'
import { Button, CodeBlock } from 'ui'
import { HoverCard_Shadcn_, HoverCardTrigger_Shadcn_, HoverCardContent_Shadcn_ } from 'ui'
import { X } from 'lucide-react'
interface SnippetRowProps {
snippets: string[]
onRemoveSnippet?: (index: number) => void
className?: string
}
export const SnippetRow: React.FC<SnippetRowProps> = ({
snippets,
onRemoveSnippet,
className = '',
}) => {
if (!snippets || snippets.length === 0) return null
return (
<div className={`w-full overflow-x-auto flex gap-2 ${className}`}>
{snippets.map((snippet, idx) => (
<HoverCard_Shadcn_ key={idx}>
<HoverCardTrigger_Shadcn_ asChild>
<div
tabIndex={0}
className="border inline-flex gap-1 items-center shrink-0 py-1 pl-2 rounded-full pr-1 text-xs cursor-pointer"
>
Snippet {idx + 1}
{onRemoveSnippet && (
<Button
size="tiny"
type="text"
className="!h-4 !w-4 rounded-full p-0"
onClick={(e) => {
e.stopPropagation()
onRemoveSnippet(idx)
}}
aria-label={`Remove snippet ${idx + 1}`}
icon={<X strokeWidth={1.5} className="!h-3 !w-3" />}
/>
)}
</div>
</HoverCardTrigger_Shadcn_>
<HoverCardContent_Shadcn_ className="w-96 max-h-64 overflow-auto p-0">
<CodeBlock
hideLineNumbers
className="text-xs font-mono whitespace-pre-wrap break-words p-2 border-0"
language="sql"
>
{snippet}
</CodeBlock>
</HoverCardContent_Shadcn_>
</HoverCard_Shadcn_>
))}
</div>
)
}