Files
supabase/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
Saxon Fletcher d0135231fb chore(studio): improve assistant feed performance (#50888)
## Problem

Assistant conversations with multiple query and Edge Function blocks
repeatedly render expensive content while streaming. Scrolling past the
feed boundary can also move the surrounding layout.

## Solution

Memoize unchanged messages, blocks, and code highlighting; batch
streaming UI updates; and skip off-screen query layout while keeping
block state mounted. Preserve streamed status updates and contain
scrolling in the message viewport. The changes are shared by Next and
TanStack.

## Review instructions

1. Compare the base branch and this branch using the same saved
conversation containing 10–20 query, result/chart, and Edge Function
blocks. Keep the browser, viewport, and conversation identical.
2. In Chrome DevTools, record Performance with 4× CPU throttling while
streaming a follow-up, typing in the composer, and scrolling through the
feed. Compare scripting/layout time and long tasks. React DevTools
Profiler should show unchanged completed blocks avoiding renders during
subsequent text updates.
3. Scroll away from query blocks and return. Confirm results, display
settings, selections, and controls retain their state. Run a read-only
query such as `select 1` and check its results still update.
4. Confirm “Thinking…” finishes, Stop retains the latest streamed text,
and approval/skip, copy, edit, and branch actions still work. Repeated
scrolling at the feed boundary must leave the outer layout/composer
stationary; jump-to-latest and following new messages should still work.
5. Repeat in both runtimes: `STUDIO_FRAMEWORK=next pnpm dev:studio` and
`STUDIO_FRAMEWORK=tanstack pnpm dev:studio`. Also check the assistant
sidebar, which shares the feed.

## Validation

- 170 assistant/Explorer tests and one shared CodeBlock test passed;
formatting and Studio lint passed (two existing warnings).
- Browser checks covered both route entry points, viewport
state/geometry, and scroll behavior.
- Review fixes: 23 focused tests, lint, formatting, and full Studio
typechecking passed. Full production builds were not verified.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] No docs content changed; docs authoring skills are not applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Performance**
* Improved responsiveness in the AI assistant by reducing unnecessary
updates while messages stream and conversation history is displayed.
* Optimized query previews, message rendering, and code blocks to keep
the interface smoother during use.
* **Bug Fixes**
* Improved handling of message edits and deletions during generation,
and preserved the latest response when generation is stopped.
* Improved conversation scrolling behavior while keeping conversation
content and scroll areas working as expected.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-25 18:09:34 +10:00

261 lines
8.0 KiB
TypeScript

import dynamic from 'next/dynamic'
import Link from 'next/link'
import React, { isValidElement, memo, ReactNode, useMemo, 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 { AssistantQueryCell } from './AssistantQueryCell'
import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
import { defaultUrlTransform, wrapPlaceholderUrls } from './Message.utils'
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 className="opacity-100">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button asChild variant="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 const MessageMarkdown = memo(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,
readOnly,
}: {
children: any
id: string
isLoading: boolean
readOnly?: boolean
}) => {
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)
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 />
) : (
<AssistantQueryCell
id={toolCallId}
sql={cleanContent}
title={title}
view={isChart ? 'chart' : 'table'}
xAxis={xAxis}
yAxis={yAxis}
isStreaming={isLoading}
/>
)
) : (
<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>
)
}