Files
supabase/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
T
626eb30e77 Assistant action orientated approach (#38806)
* update onboarding

* update model and fix part issue

* action orientated assistant

* fix tool

* lock

* remove unused filter

* fix tests

* fix again

* update package

* update container

* fix tests

* refactor(ai assistant): break out message markdown and profile picture

* wip

* refactor(ai assistant): break up message component

* refactor: break ai assistant message down into multiple files

* refactor: simplify ReportBlock state

* fix: styling of draggable report block header

When the drag handle is showing, it overlaps with the block header.
Decrease the opacity of the header so the handle can be seen and the two
can be distinguished.

* fix: minor tweaks to tool ui

* refactor: simplify DisplayBlockRenderer state

* fix: remove double deploy button in edge function block

When the confirm footer is shown, the deploy button on the top right should be
hidden (not just disabled) to avoid confusion.

* refactor, test: message sanitization by opt-in level

Refactor the message sanitization to have more type safety and be more testable.
Add tests to ensure:

- Message sanitization always runs on generate-v4
- Message sanitization correctly works by opt-in level

* Fix conflicts in pnpm lock

* Couple of nits and refactors

* Revert casing for report block snippet

* adjust sanitised prompt

* Fix tests

---------

Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2025-09-29 03:57:36 +00:00

455 lines
16 KiB
TypeScript

import { Book, Save, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
createSqlSnippetSkeletonV2,
suffixWithLimit,
} from 'components/interfaces/SQLEditor/SQLEditor.utils'
import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results'
import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton'
import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { BASE_PATH } from 'lib/constants'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
Button,
cn,
CodeBlock,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Form_Shadcn_,
FormField_Shadcn_,
HoverCard_Shadcn_,
HoverCardContent_Shadcn_,
HoverCardTrigger_Shadcn_,
Input_Shadcn_ as Input,
KeyboardShortcut,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SQL_ICON,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils'
import AIEditor from '../AIEditor'
import { ButtonTooltip } from '../ButtonTooltip'
import { InlineLink } from '../InlineLink'
import { SqlWarningAdmonition } from '../SqlWarningAdmonition'
type Template = {
name: string
description: string
content: string
}
interface EditorPanelProps {
open: boolean
onClose: () => void
initialValue?: string
label?: string
saveLabel?: string
saveValue?: string
onSave?: (value: string, saveValue: string) => void
onRunSuccess?: (value: any[]) => void
onRunError?: (value: any) => void
functionName?: string
templates?: Template[]
initialPrompt?: string
onChange?: (value: string) => void
}
export const EditorPanel = ({
open,
onClose,
initialValue = '',
label = '',
saveLabel = 'Save',
saveValue = '',
onSave,
onRunSuccess,
onRunError,
templates = [],
initialPrompt = '',
onChange,
}: EditorPanelProps) => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { profile } = useProfile()
const snapV2 = useSqlEditorV2StateSnapshot()
const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
const { data: org } = useSelectedOrganizationQuery()
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<QueryResponseError>()
const [results, setResults] = useState<undefined | any[]>(undefined)
const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
const [currentValue, setCurrentValue] = useState(initialValue)
const [showResults, setShowResults] = useState(true)
const [isTemplatesOpen, setIsTemplatesOpen] = useState(false)
const saveForm = useForm({
defaultValues: {
saveValue: saveValue || '',
},
})
const errorHeader = error?.formattedError?.split('\n')?.filter((x: string) => x.length > 0)?.[0]
const errorContent =
'formattedError' in (error || {})
? error?.formattedError
?.split('\n')
?.filter((x: string) => x.length > 0)
?.slice(1) ?? []
: [error?.message ?? '']
const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess: async (res) => {
setResults(res.result)
if (onRunSuccess) {
onRunSuccess(res.result)
}
},
onError: (error) => {
setError(error)
setResults([])
if (onRunError) {
onRunError(error)
}
},
})
const onExecuteSql = (skipValidation = false) => {
setError(undefined)
setShowWarning(undefined)
if (currentValue.length === 0) return
if (!skipValidation) {
const isReadOnlySelectSQL = isReadOnlySelect(currentValue)
if (!isReadOnlySelectSQL) {
const hasUnknownFunctions = containsUnknownFunction(currentValue)
setShowWarning(hasUnknownFunctions ? 'hasUnknownFunctions' : 'hasWriteOperation')
return
}
}
executeSql({
sql: suffixWithLimit(currentValue, 100),
projectRef: project?.ref,
connectionString: project?.connectionString,
handleError: (error) => {
throw error
},
contextualInvalidation: true,
})
}
const handleChange = (value: string) => {
setCurrentValue(value)
onChange?.(value)
}
const onSelectTemplate = (content: string) => {
handleChange(content)
setIsTemplatesOpen(false)
}
useEffect(() => {
if (initialValue !== undefined && initialValue !== currentValue) {
setCurrentValue(initialValue)
setResults(undefined)
setError(undefined)
setShowWarning(undefined)
}
}, [initialValue])
useEffect(() => {
saveForm.reset({
saveValue: saveValue || '',
})
}, [saveValue, saveForm])
return (
<Sheet open={open} onOpenChange={(open) => !open && onClose()}>
<SheetContent
showClose={false}
className="w-full sm:max-w-3xl flex flex-col h-full p-0 space-y-0 gap-0"
>
<SheetHeader className="flex shrink-0 items-center gap-x-3">
<div className="flex-1">
<SheetTitle className="text-sm">SQL Editor</SheetTitle>
{label && <SheetDescription className="text-sm">{label}</SheetDescription>}
</div>
<div className="flex gap-2 items-center">
{templates.length > 0 && (
<Popover_Shadcn_ open={isTemplatesOpen} onOpenChange={setIsTemplatesOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="tiny"
type="default"
role="combobox"
aria-expanded={isTemplatesOpen}
icon={<Book size={14} />}
>
Templates
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ align="end" className="w-[300px] p-0">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search templates..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No templates found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{templates.map((template) => (
<HoverCard_Shadcn_ key={template.name}>
<HoverCardTrigger_Shadcn_ asChild>
<CommandItem_Shadcn_
value={template.name}
onSelect={() => onSelectTemplate(template.content)}
className="cursor-pointer"
>
<div className="flex items-center gap-3">
<SQL_ICON
size={16}
className={cn(
'w-5 h-5 flex-0 mr-2 transition-colors fill-foreground-muted'
)}
/>
<div className="flex-1">
<h4 className="text-foreground flex-1">{template.name}</h4>
<p className="text-xs text-foreground-light">
{template.description}
</p>
</div>
</div>
</CommandItem_Shadcn_>
</HoverCardTrigger_Shadcn_>
<HoverCardContent_Shadcn_ side="left" className="w-[500px] p-0">
<CodeBlock
language="sql"
className="language-sql border-none"
hideLineNumbers
value={template.content}
/>
</HoverCardContent_Shadcn_>
</HoverCard_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)}
<ButtonTooltip
tooltip={{
content: {
side: 'bottom',
text: 'Save as snippet',
},
}}
size="tiny"
type="default"
className="w-7 h-7"
loading={isSaving}
icon={<Save size={16} />}
onClick={async () => {
if (!ref) return console.error('Project ref is required')
if (!project) return console.error('Project is required')
if (!profile) return console.error('Profile is required')
try {
setIsSaving(true)
const { title: name } = await generateSqlTitle({
sql: currentValue,
})
const snippet = createSqlSnippetSkeletonV2({
id: uuidv4(),
name,
sql: currentValue,
owner_id: profile.id,
project_id: project.id,
})
snapV2.addSnippet({ projectRef: ref, snippet })
snapV2.addNeedsSaving(snippet.id)
toast.success(
<div>
Saved snippet! View it{' '}
<InlineLink href={`/project/${ref}/sql/${snippet.id}`}>here</InlineLink>
</div>
)
} catch (error: any) {
toast.error(`Failed to create new query: ${error.message}`)
} finally {
setIsSaving(false)
}
}}
/>
<ButtonTooltip
size="tiny"
type="default"
className="w-7 h-7"
onClick={onClose}
icon={<X size={16} />}
tooltip={{
content: {
side: 'bottom',
text: (
<div className="flex items-center gap-4">
<span>Close Editor</span>
<KeyboardShortcut keys={['Meta', 'e']} />
</div>
),
},
}}
/>
</div>
</SheetHeader>
<div className="flex-1 overflow-hidden flex flex-col h-full">
<div className="flex-1 min-h-0 relative">
<AIEditor
autoFocus
language="pgsql"
value={currentValue}
onChange={handleChange}
aiEndpoint={`${BASE_PATH}/api/ai/code/complete`}
aiMetadata={{
projectRef: project?.ref,
connectionString: project?.connectionString,
orgSlug: org?.slug,
}}
initialPrompt={initialPrompt}
options={{
tabSize: 2,
fontSize: 13,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
folding: false,
padding: { top: 16 },
lineNumbersMinChars: 3,
}}
executeQuery={onExecuteSql}
onClose={() => onClose()}
/>
</div>
{error !== undefined && (
<div className="shrink-0">
<Admonition
type="warning"
className="m-0 rounded-none border-x-0 border-b-0 [&>div>div>pre]:text-sm [&>div]:flex [&>div]:flex-col [&>div]:gap-y-2"
title={errorHeader || 'Error running SQL query'}
description={
<div>
{errorContent.length > 0 ? (
errorContent.map((errorText: string, i: number) => (
<pre key={`err-${i}`} className="font-mono text-xs whitespace-pre-wrap">
{errorText}
</pre>
))
) : (
<p className="font-mono text-xs">{error.error}</p>
)}
</div>
}
/>
</div>
)}
{showWarning && (
<SqlWarningAdmonition
className="border-t"
warningType={showWarning}
onCancel={() => setShowWarning(undefined)}
onConfirm={() => {
setShowWarning(undefined)
onExecuteSql(true)
}}
/>
)}
{results !== undefined && results.length > 0 && (
<div className={`max-h-72 shrink-0 flex flex-col ${showResults && 'h-full'}`}>
{showResults && (
<div className="border-t flex-1 overflow-auto">
<Results rows={results} />
</div>
)}
<p className="text-xs text-foreground-light border-t py-2 px-5 flex items-center justify-between">
<span className="font-mono">
{results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`}
</span>
<Button
size="tiny"
type="default"
className="ml-2"
onClick={() => setShowResults((prev) => !prev)}
>
{showResults ? 'Hide Results' : 'Show Results'}
</Button>
</p>
</div>
)}
{results !== undefined && results.length === 0 && !error && (
<div className="shrink-0">
<p className="text-xs text-foreground-light font-mono py-2 px-5">
Success. No rows returned.
</p>
</div>
)}
<div className="flex items-center gap-2 justify-end px-5 py-4 w-full border-t shrink-0">
{onSave && (
<Form_Shadcn_ {...saveForm}>
<form
onSubmit={saveForm.handleSubmit((values) => {
onSave(currentValue, values.saveValue)
})}
className="flex items-center gap-2"
>
{saveValue && (
<FormField_Shadcn_
control={saveForm.control}
name="saveValue"
render={({ field }) => (
<Input size="tiny" placeholder="Enter save value..." {...field} />
)}
/>
)}
<Button size="tiny" type="default" htmlType="submit">
{saveLabel}
</Button>
</form>
</Form_Shadcn_>
)}
<SqlRunButton
isDisabled={isExecuting}
isExecuting={isExecuting}
onClick={onExecuteSql}
/>
</div>
</div>
</SheetContent>
</Sheet>
)
}
export default EditorPanel