mirror of
https://github.com/supabase/supabase.git
synced 2026-06-16 02:26:42 +08:00
- Most changes are related to either types or `useRef` usages (it now requires an initial value). - also updated `vaul` to its latest version and haven't noticed any change ([design-system demo](https://design-system-git-react-19-supabase.vercel.app/design-system/docs/components/drawer)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Upgraded workspace to React 19. * **Bug Fixes** * Improved null-safety and ref handling across editors, UI components, shortcuts, and markdown/image rendering to reduce runtime errors. * Safer event/timeout/interval cleanup and more robust command/context handling. * **Chores** * Bumped vaul dependency versions. * **Documentation** * Type and TypeScript accuracy improvements for clearer developer feedback. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45886) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
300 lines
9.0 KiB
TypeScript
300 lines
9.0 KiB
TypeScript
import { untrustedSql } from '@supabase/pg-meta'
|
|
import dynamic from 'next/dynamic'
|
|
import Link from 'next/link'
|
|
import React, {
|
|
isValidElement,
|
|
memo,
|
|
ReactNode,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
type ReactElement,
|
|
} from 'react'
|
|
import type { StreamdownProps } from 'streamdown'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogSection,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from 'ui'
|
|
import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
|
|
import { markdownComponents } from 'ui-patterns/Markdown'
|
|
|
|
import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock'
|
|
import { AssistantSnippetProps } from './AIAssistant.types'
|
|
import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
|
|
import { DisplayBlockRenderer } from './DisplayBlockRenderer'
|
|
import { defaultUrlTransform, wrapPlaceholderUrls } from './Message.utils'
|
|
import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
|
|
|
const Streamdown = dynamic<StreamdownProps>(
|
|
() => import('streamdown').then((mod) => mod.Streamdown),
|
|
{ ssr: false }
|
|
)
|
|
|
|
// Streamdown splits ordered lists with complex content (e.g. code blocks) into
|
|
// separate <ol> elements. The `start` attribute preserves semantics for screen
|
|
// readers, while `counterReset` is what actually fixes the visible numbering —
|
|
// the prose config (tailwind.config.ts) uses a custom CSS counter named "item"
|
|
// with `listStyleType: 'none'`, so the `start` attribute alone has no visual effect.
|
|
export const OrderedList = memo(({ children, start }: { children?: ReactNode; start?: number }) => (
|
|
<ol
|
|
className="flex flex-col gap-y-4"
|
|
start={start}
|
|
style={start !== undefined ? { counterReset: `item ${start - 1}` } : undefined}
|
|
>
|
|
{children}
|
|
</ol>
|
|
))
|
|
OrderedList.displayName = 'OrderedList'
|
|
|
|
export const ListItem = memo(({ children }: { children?: ReactNode }) => (
|
|
<li className="[&>pre]:mt-2">{children}</li>
|
|
))
|
|
ListItem.displayName = 'ListItem'
|
|
|
|
export const Heading3 = memo(({ children }: { children?: ReactNode }) => (
|
|
<h3 className="underline">{children}</h3>
|
|
))
|
|
Heading3.displayName = 'Heading3'
|
|
|
|
export const InlineCode = memo(
|
|
({ className, children }: { className?: string; children?: ReactNode }) => (
|
|
<code className={cn('text-xs', className)}>{children}</code>
|
|
)
|
|
)
|
|
InlineCode.displayName = 'InlineCode'
|
|
|
|
export const Hyperlink = memo(({ href, children }: { href?: string; children?: ReactNode }) => {
|
|
const isExternalURL = !href?.startsWith('https://supabase.com/dashboard')
|
|
const safeUrl = defaultUrlTransform(href ?? '')
|
|
const isSafeUrl = safeUrl.length > 0
|
|
|
|
if (!isSafeUrl) {
|
|
return <span className="text-foreground">{children}</span>
|
|
}
|
|
|
|
return (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<span
|
|
className={cn(
|
|
'm-0! text-foreground cursor-pointer transition',
|
|
'underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground-lighter'
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
</DialogTrigger>
|
|
<DialogContent size="small">
|
|
<DialogHeader className="border-b">
|
|
<DialogTitle>Verify the link before navigating</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<DialogSection className="flex flex-col">
|
|
<p className="text-sm text-foreground-light">
|
|
This link will take you to the following URL:
|
|
</p>
|
|
<p className="text-sm text-foreground">{safeUrl}</p>
|
|
<p className="text-sm text-foreground-light mt-2">Are you sure you want to head there?</p>
|
|
</DialogSection>
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button type="default" className="opacity-100">
|
|
Cancel
|
|
</Button>
|
|
</DialogClose>
|
|
<DialogClose asChild>
|
|
<Button asChild type="primary" className="opacity-100">
|
|
{isExternalURL ? (
|
|
<a href={safeUrl} target="_blank" rel="noreferrer noopener">
|
|
Head to link
|
|
</a>
|
|
) : (
|
|
<Link href={safeUrl}>Head to link</Link>
|
|
)}
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
})
|
|
Hyperlink.displayName = 'Hyperlink'
|
|
|
|
const baseMarkdownComponents = {
|
|
ol: OrderedList,
|
|
li: ListItem,
|
|
h3: Heading3,
|
|
code: InlineCode,
|
|
a: Hyperlink,
|
|
img: ({ src }: React.JSX.IntrinsicElements['img']) => (
|
|
<span className="text-foreground-light font-mono">[Image: {src?.toString()}]</span>
|
|
),
|
|
}
|
|
|
|
export function MessageMarkdown({
|
|
id,
|
|
isLoading,
|
|
readOnly,
|
|
className,
|
|
children,
|
|
}: {
|
|
id: string
|
|
isLoading: boolean
|
|
readOnly?: boolean
|
|
className?: string
|
|
children: ReactNode
|
|
}) {
|
|
const markdownSource = useMemo(() => {
|
|
if (typeof children === 'string') {
|
|
return wrapPlaceholderUrls(children)
|
|
}
|
|
if (Array.isArray(children)) {
|
|
return wrapPlaceholderUrls(
|
|
children.filter((child): child is string => typeof child === 'string').join('')
|
|
)
|
|
}
|
|
return ''
|
|
}, [children])
|
|
|
|
const allMarkdownComponents = useMemo(
|
|
() => ({
|
|
...markdownComponents,
|
|
...baseMarkdownComponents,
|
|
pre: (props: React.JSX.IntrinsicElements['pre']) => (
|
|
<MarkdownPre id={id} isLoading={isLoading} readOnly={readOnly}>
|
|
{props.children}
|
|
</MarkdownPre>
|
|
),
|
|
}),
|
|
[id, isLoading, readOnly]
|
|
)
|
|
|
|
return (
|
|
<Streamdown className={className} components={allMarkdownComponents}>
|
|
{markdownSource}
|
|
</Streamdown>
|
|
)
|
|
}
|
|
|
|
export const MarkdownPre = ({
|
|
children,
|
|
id,
|
|
isLoading: _isLoading,
|
|
readOnly,
|
|
}: {
|
|
children: any
|
|
id: string
|
|
isLoading: boolean
|
|
readOnly?: boolean
|
|
}) => {
|
|
// [Joshen] Using a ref as this data doesn't need to trigger a re-render
|
|
const chartConfig = useRef<ChartConfig>({
|
|
view: 'table',
|
|
type: 'bar',
|
|
xKey: '',
|
|
yKey: '',
|
|
cumulative: false,
|
|
})
|
|
|
|
const childArray = Array.isArray(children) ? children : [children]
|
|
const codeElement = childArray.find(
|
|
(child): child is ReactElement<{ className?: string; children: ReactNode }> =>
|
|
isValidElement<{ className?: string; children: ReactNode }>(child)
|
|
)
|
|
const codeProps = codeElement?.props || ({} as { className?: string; children: ReactNode })
|
|
const language = codeProps.className?.replace('language-', '') || 'sql'
|
|
const codeChildren = codeProps.children
|
|
const rawContent = Array.isArray(codeChildren)
|
|
? codeChildren.map((node) => (typeof node === 'string' ? node : '')).join('')
|
|
: typeof codeChildren === 'string'
|
|
? codeChildren
|
|
: ''
|
|
const propsMatch = rawContent.match(/(?:--|\/\/)\s*props:\s*(\{[^}]+\})/)
|
|
|
|
const snippetProps: AssistantSnippetProps = useMemo(() => {
|
|
try {
|
|
if (propsMatch) {
|
|
return JSON.parse(propsMatch[1])
|
|
}
|
|
} catch {}
|
|
return {}
|
|
}, [propsMatch])
|
|
|
|
const { xAxis, yAxis } = snippetProps
|
|
const snippetId = snippetProps.id
|
|
const title = snippetProps.title || (language === 'edge' ? 'Edge Function' : 'SQL Query')
|
|
const isChart = snippetProps.isChart === 'true'
|
|
// Strip props from the content for both SQL and edge functions
|
|
const cleanContent = rawContent.replace(/(?:--|\/\/)\s*props:\s*\{[^}]+\}/, '').trim()
|
|
|
|
const toolCallId = String(snippetId ?? id)
|
|
|
|
useEffect(() => {
|
|
chartConfig.current = {
|
|
...chartConfig.current,
|
|
view: isChart ? 'chart' : 'table',
|
|
xKey: xAxis ?? '',
|
|
yKey: yAxis ?? '',
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [snippetProps])
|
|
|
|
if (!codeElement) {
|
|
return <pre className="w-auto overflow-x-auto not-prose my-4">{children}</pre>
|
|
}
|
|
|
|
return (
|
|
<div className="w-auto overflow-x-hidden not-prose my-4 ">
|
|
{language === 'edge' ? (
|
|
<EdgeFunctionBlock
|
|
label={title}
|
|
code={cleanContent}
|
|
functionName={snippetProps.name || 'my-function'}
|
|
showCode={!readOnly}
|
|
/>
|
|
) : language === 'sql' ? (
|
|
readOnly ? (
|
|
<CollapsibleCodeBlock value={cleanContent} language="sql" hideLineNumbers />
|
|
) : (
|
|
<DisplayBlockRenderer
|
|
messageId={id}
|
|
toolCallId={toolCallId}
|
|
initialArgs={{
|
|
sql: untrustedSql(cleanContent),
|
|
label: title,
|
|
isWriteQuery: false,
|
|
view: isChart ? 'chart' : 'table',
|
|
xAxis: xAxis ?? '',
|
|
yAxis: yAxis ?? '',
|
|
}}
|
|
onError={() => {}}
|
|
showConfirmFooter={false}
|
|
onChartConfigChange={(config) => {
|
|
chartConfig.current = { ...config }
|
|
}}
|
|
/>
|
|
)
|
|
) : (
|
|
<CodeBlock
|
|
hideLineNumbers
|
|
value={cleanContent}
|
|
language={language as CodeBlockLang}
|
|
className={cn(
|
|
'my-4 max-h-96 max-w-none block border rounded-sm bg-transparent! py-3! px-3.5! prose dark:prose-dark text-foreground',
|
|
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block [&>code>span]:text-foreground'
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|