mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## 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 -->
204 lines
6.6 KiB
TypeScript
204 lines
6.6 KiB
TypeScript
import { useMemo, useRef, useState } from 'react'
|
|
|
|
import { identifyQueryType } from './AIAssistant.utils'
|
|
import {
|
|
changeAssistantQuerySource,
|
|
createAssistantQueryModel,
|
|
DEFAULT_ASSISTANT_LOGS_QUERY_TITLE,
|
|
DEFAULT_ASSISTANT_QUERY_TITLE,
|
|
getAssistantQueryDisplay,
|
|
setAssistantQuerySql,
|
|
shouldClearAssistantQueryResult,
|
|
} from './AssistantQueryCell.utils'
|
|
import { Confirm } from './Confirm'
|
|
import { type ConfirmFooterApprovalState } from './Confirm.utils'
|
|
import { useIsExplorerEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
|
import { QueryEditor } from '@/components/interfaces/Explorer/QueryEditor'
|
|
import { SaveQueryDropdown } from '@/components/interfaces/Explorer/SaveQueryDropdown'
|
|
import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types'
|
|
import {
|
|
type QuerySourceBinding,
|
|
type QuerySourceTag,
|
|
} from '@/data/query-sources/query-source-registry'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { useAiAssistantState } from '@/state/ai-assistant-state'
|
|
import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state'
|
|
|
|
interface AssistantQueryCellProps {
|
|
id: string
|
|
sql: string
|
|
title?: string
|
|
initialResult?: QueryResult
|
|
source?: QuerySourceBinding
|
|
view?: 'table' | 'chart'
|
|
xAxis?: string
|
|
yAxis?: string
|
|
/** Follow incoming SQL while the assistant is still streaming the query text. */
|
|
isStreaming?: boolean
|
|
confirmState?: ConfirmFooterApprovalState
|
|
onApprove?: () => void
|
|
onDeny?: () => void
|
|
}
|
|
|
|
const DEFAULT_SOURCE: QuerySourceBinding = { _tag: 'database' }
|
|
|
|
const QUERY_OUTCOME_MESSAGES: Record<
|
|
QuerySourceTag,
|
|
{ success: string; error: string; denied: string }
|
|
> = {
|
|
database: {
|
|
success: 'Query executed',
|
|
error: 'Failed to execute SQL',
|
|
denied: 'Skipped query',
|
|
},
|
|
logs: {
|
|
success: 'Query executed',
|
|
error: 'Failed to query logs',
|
|
denied: 'Skipped query',
|
|
},
|
|
}
|
|
|
|
/** Assistant adapter around the shared QueryEditor. Local state only — nothing is persisted. */
|
|
export const AssistantQueryCell = ({
|
|
id,
|
|
sql: initialSql,
|
|
title: initialTitle,
|
|
initialResult,
|
|
source = DEFAULT_SOURCE,
|
|
view,
|
|
xAxis,
|
|
yAxis,
|
|
isStreaming = false,
|
|
confirmState,
|
|
onApprove,
|
|
onDeny,
|
|
}: AssistantQueryCellProps) => {
|
|
const track = useTrack()
|
|
const roleImpersonationState = useLocalRoleImpersonationState()
|
|
const aiAssistantState = useAiAssistantState()
|
|
const isExplorerEnabled = useIsExplorerEnabled()
|
|
|
|
const fallbackTitle =
|
|
initialTitle?.trim() ||
|
|
(source._tag === 'logs' ? DEFAULT_ASSISTANT_LOGS_QUERY_TITLE : DEFAULT_ASSISTANT_QUERY_TITLE)
|
|
|
|
const hasExplicitAxes = Boolean(xAxis || yAxis)
|
|
|
|
const [title, setTitle] = useState(fallbackTitle)
|
|
const [query, setQuery] = useState(() => createAssistantQueryModel(initialSql, source))
|
|
const [showQuery, setShowQuery] = useState(true)
|
|
// undefined uses the tool output; null intentionally clears it after changing source.
|
|
const [resultOverride, setResultOverride] = useState<QueryResult | null>()
|
|
const [localDisplay, setLocalDisplay] = useState<QueryDisplay | undefined>(undefined)
|
|
const previousId = useRef(id)
|
|
|
|
if (previousId.current !== id) {
|
|
previousId.current = id
|
|
setTitle(fallbackTitle)
|
|
setQuery(createAssistantQueryModel(initialSql, source))
|
|
setShowQuery(true)
|
|
setResultOverride(undefined)
|
|
setLocalDisplay(undefined)
|
|
}
|
|
|
|
if (isStreaming && query.uncheckedSql !== initialSql) {
|
|
setQuery((current) => setAssistantQuerySql(current, initialSql))
|
|
}
|
|
|
|
const result = resultOverride === undefined ? initialResult : (resultOverride ?? undefined)
|
|
const inferredDisplay = useMemo(
|
|
() =>
|
|
getAssistantQueryDisplay({
|
|
view,
|
|
xAxis,
|
|
yAxis,
|
|
sql: query.uncheckedSql,
|
|
rows: result?.rows,
|
|
}),
|
|
[view, xAxis, yAxis, query.uncheckedSql, result?.rows]
|
|
)
|
|
const display = localDisplay ?? inferredDisplay
|
|
|
|
const handleTitleChange = (value: string) => {
|
|
const nextTitle = value.trim()
|
|
if (!nextTitle) return
|
|
setTitle(nextTitle)
|
|
}
|
|
|
|
const handleSourceChange = (nextSource: QuerySourceBinding) => {
|
|
const isBackendChange = nextSource._tag !== query._tag
|
|
if (shouldClearAssistantQueryResult(query, nextSource)) setResultOverride(null)
|
|
if (isBackendChange && !hasExplicitAxes) setLocalDisplay(undefined)
|
|
setQuery((current) => changeAssistantQuerySource(current, nextSource))
|
|
}
|
|
|
|
const handleDisplayChange = (nextDisplay: QueryDisplay) => {
|
|
setLocalDisplay(nextDisplay)
|
|
}
|
|
|
|
const handleResultChange = (nextResult: QueryResult) => {
|
|
setResultOverride(nextResult)
|
|
}
|
|
|
|
const handleRun = () => {
|
|
const sql = query.uncheckedSql
|
|
const mutationType = identifyQueryType(sql)
|
|
track('assistant_suggestion_run_query_clicked', {
|
|
queryType: mutationType ? 'mutation' : 'select',
|
|
...(mutationType ? { mutationType } : {}),
|
|
})
|
|
}
|
|
|
|
const isRunDisabled =
|
|
confirmState === 'approval-requested' || confirmState === 'approval-responded'
|
|
const outcomeMessages = QUERY_OUTCOME_MESSAGES[source._tag]
|
|
|
|
return (
|
|
<Confirm
|
|
fill
|
|
className="w-full max-w-3xl mx-auto"
|
|
state={confirmState}
|
|
message="Assistant wants to run this query"
|
|
cancelLabel="Skip"
|
|
confirmLabel="Run query"
|
|
confirmLabelLoading="Running..."
|
|
successMessage={outcomeMessages.success}
|
|
errorMessage={outcomeMessages.error}
|
|
deniedMessage={outcomeMessages.denied}
|
|
onCancel={onDeny}
|
|
onConfirm={onApprove}
|
|
>
|
|
{/* Keep editor state mounted; the fixed height preserves scroll geometry when skipped. */}
|
|
<QueryEditor
|
|
isReadOnly
|
|
id={id}
|
|
variant="viewport"
|
|
className="h-96 [content-visibility:auto] [contain-intrinsic-block-size:auto_24rem]"
|
|
title={title}
|
|
query={query}
|
|
result={result}
|
|
showQuery={showQuery}
|
|
onShowQueryChange={setShowQuery}
|
|
roleImpersonationState={roleImpersonationState}
|
|
display={display}
|
|
isRunDisabled={isRunDisabled}
|
|
onTitleChange={handleTitleChange}
|
|
onSqlChange={(sql) => setQuery((current) => setAssistantQuerySql(current, sql))}
|
|
onSourceChange={handleSourceChange}
|
|
onResultChange={handleResultChange}
|
|
onRowLimitChange={(rowLimit) =>
|
|
setQuery((current) => (current._tag === 'database' ? { ...current, rowLimit } : current))
|
|
}
|
|
onDisplayChange={handleDisplayChange}
|
|
onRun={handleRun}
|
|
onDebug={aiAssistantState.setInitialInput}
|
|
toolbarActions={
|
|
isExplorerEnabled && !isStreaming ? (
|
|
<SaveQueryDropdown query={{ title, sql: query.uncheckedSql }} source={query} />
|
|
) : undefined
|
|
}
|
|
/>
|
|
</Confirm>
|
|
)
|
|
}
|