Files
supabase/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
Gildas Garcia 86a3f8b03d chore: upgrade to react-19 (#45886)
- 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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45886)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 16:04:41 +02:00

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>
)
}