Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx
Saxon Fletcher 9b17ce8f2c chore(studio): default assistant to GPT-5.6 Luna (#49749)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature / chore: hide assistant model selection in the UI and default
chats to GPT-5.6 Luna.

## What is the current behavior?

The assistant composer exposes a model picker. Paid orgs default to
`gpt-5.3-codex`; everyone else defaults to `gpt-5.4-nano`.

## What is the new behavior?

- The model picker is hidden in the assistant composer and Explorer
home.
- Chats default to `gpt-5.6-luna` with `reasoningEffort: medium`.
- Model selection plumbing is kept (registry, entitlements, `setModel`,
generate-v4 request body) so a requested model can still be honored when
provided.
- Other completion endpoints still use `gpt-5.4-nano`.

## Additional context

Model selector UI can be re-enabled by passing `selectedModel` /
`onSelectModel` to `AssistantChatForm`.


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

## Summary by CodeRabbit

* **New Features**
* Added support for the GPT-5.6 Luna model with medium reasoning
capability.
  * Made GPT-5.6 Luna the default assistant model.
* **Improvements**
* Simplified assistant chat by removing model selection from the primary
chat experience.
  * Updated model fallback behavior to use the standard assistant model.
* Chat forms can now optionally display model selection when configured.
* **Tests**
* Updated model coverage and assistant chat tests for the new defaults
and behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:24:37 +10:00

688 lines
24 KiB
TypeScript

import type { UIMessage as MessageType } from '@ai-sdk/react'
import { useChat } from '@ai-sdk/react'
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { useParams, useSearchParamsShallow } from 'common/hooks'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { Eraser, Pencil, X } from 'lucide-react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Button, cn, KeyboardShortcut } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { AlertError } from '../AlertError'
import { ButtonTooltip } from '../ButtonTooltip'
import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'
import { InlineLinkClassName } from '../InlineLink'
import { ASSISTANT_ERRORS } from './AiAssistant.constants'
import {
containsLogsSnippets,
hasPendingToolApproval,
onErrorChat,
resolvePendingToolApprovalsAsDenied,
} from './AIAssistant.utils'
import { AIOnboarding } from './AIOnboarding'
import { AssistantChatForm } from './AssistantChatForm'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from './elements/Conversation'
import { Message } from './Message'
import { Markdown } from '@/components/interfaces/Markdown'
import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query'
import { useRateMessageMutation } from '@/data/ai/rate-message-mutation'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import type { AssistantMessageMetadata } from '@/lib/ai/assistant-message-metadata'
import { getParallelApprovalIdsToReject } from '@/lib/ai/message-utils'
import { IS_PLATFORM } from '@/lib/constants'
import { uuidv4 } from '@/lib/helpers'
import { useTrack } from '@/lib/telemetry/track'
import type { SqlSnippet } from '@/state/ai-assistant-state'
import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
export interface AssistantChatHeaderProps {
isChatLoading: boolean
showMetadataWarning: boolean
updatedOptInSinceMCP: boolean
isHipaaProjectDisallowed: boolean
aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined
}
export interface AssistantChatComposerContext {
initialInput?: string
sqlSnippets?: SqlSnippet[]
suggestions?: { title?: string; prompts?: { label: string; description: string }[] }
onSetSqlSnippets?: (snippets: SqlSnippet[]) => void
onClearSqlSnippets?: () => void
}
interface AssistantChatProps {
className?: string
chatId: string
shortcutsEnabled?: boolean
onNewChat: () => void
onSelectChat: (chatId: string) => void
onBranchChat: (messageId: string) => void
composerContext?: AssistantChatComposerContext
renderHeader?: (props: AssistantChatHeaderProps) => ReactNode
onInputChange?: (value: string) => void
}
export const AssistantChat = ({
className,
chatId,
shortcutsEnabled = true,
onNewChat,
onSelectChat,
onBranchChat,
composerContext,
renderHeader,
onInputChange,
}: AssistantChatProps) => {
const { id: entityId } = useParams()
const { data: project } = useSelectedProjectQuery()
const searchParams = useSearchParamsShallow()
const { data: selectedOrganization, isPending: isLoadingOrganization } =
useSelectedOrganizationQuery()
const disablePrompts = useFlag('disableAssistantPrompts')
const shouldReduceMotion = useReducedMotion()
const snap = useAiAssistantStateSnapshot()
const state = useAiAssistantState()
const currentChat = snap.chats[chatId]
useShortcut(SHORTCUT_IDS.AI_ASSISTANT_CANCEL_EDIT, () => cancelEdit(), {
enabled: shortcutsEnabled,
})
const [updatedOptInSinceMCP] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
false
)
const inputRef = useRef<HTMLTextAreaElement>(null)
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
// Whether attached queries are sent at all. One definition, shared by the chat form
// (which folds them into the message text) and the message metadata (which states
// whether any of them was a logs query), so the two can't disagree.
const includeSnippetsInMessage = aiOptInLevel !== 'disabled'
const showMetadataWarning =
IS_PLATFORM &&
!!selectedOrganization &&
(aiOptInLevel === 'disabled' || aiOptInLevel === 'schema')
// Add a ref to store the last user message
const lastUserMessageRef = useRef<MessageType | null>(null)
const [value, setValue] = useState<string>(composerContext?.initialInput || '')
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [isResubmitting, setIsResubmitting] = useState(false)
const [messageRatings, setMessageRatings] = useState<Record<string, 'positive' | 'negative'>>({})
const { data: check, isSuccess } = useCheckOpenAIKeyQuery()
const isApiKeySet = !!check?.hasKey
const { mutateAsync: rateMessage } = useRateMessageMutation()
const { data: tables } = useTablesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: 'public',
},
{ enabled: isApiKeySet }
)
const currentTable = tables?.find((t) => t.id.toString() === entityId)
const currentSchema = searchParams?.get('schema') ?? 'public'
// Update context in state
useEffect(() => {
state.setContext({
projectRef: project?.ref,
orgSlug: selectedOrganization?.slug,
connectionString: project?.connectionString ?? '',
})
}, [project?.ref, project?.connectionString, selectedOrganization?.slug, state])
const track = useTrack()
useEffect(() => {
state.ensureChatInstance(chatId)
}, [chatId, state])
const chatInstance = snap.chatInstances[chatId]
const {
messages: chatMessages,
status: chatStatus,
error,
sendMessage,
setMessages,
addToolApprovalResponse,
stop,
regenerate,
} = useChat<MessageType>({
id: chatId,
...(chatInstance ? { chat: chatInstance } : {}),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
onError: onErrorChat,
})
const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming'
const hasPendingApproval = hasPendingToolApproval(chatMessages)
const supportMetadata = currentChat?.supportMetadata
const isSupportChat = !!supportMetadata?.isSupportChat
const isSupportChatClosed = isSupportChat && supportMetadata.lifecycleStatus !== 'bot_active'
const supportConversationId = supportMetadata?.frontConversationId
const isChatInputDisabled =
!isApiKeySet || disablePrompts || isLoadingOrganization || isSupportChatClosed
const branchedFrom = currentChat?.branchedFrom
const branchedConversation = branchedFrom ? snap.chats[branchedFrom.chatId] : undefined
const deleteMessageFromHere = useCallback(
(messageId: string) => {
// Find the message index in current chatMessages
const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
if (messageIndex === -1) return
if (isChatLoading) stop()
snap.deleteMessagesAfter(messageId, { includeSelf: true, chatId })
const updatedMessages = chatMessages.slice(0, messageIndex)
setMessages(updatedMessages)
},
[snap, setMessages, chatMessages, isChatLoading, stop, chatId]
)
const editMessage = useCallback(
(messageId: string) => {
const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
if (messageIndex === -1) return
// Target message
const messageToEdit = chatMessages[messageIndex]
// Activate editing mode
setEditingMessageId(messageId)
const textContent =
messageToEdit.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('') ?? ''
setValue(textContent)
setTimeout(() => {
if (inputRef.current) {
inputRef?.current?.focus()
// [Joshen] This is just to make the cursor go to the end of the text when focusing
const val = inputRef.current.value
inputRef.current.value = ''
inputRef.current.value = val
}
}, 100)
},
[chatMessages, setValue]
)
const cancelEdit = useCallback(() => {
setEditingMessageId(null)
setValue('')
}, [setValue])
const handleRateMessage = useCallback(
async (messageId: string, rating: 'positive' | 'negative', reason?: string) => {
if (!project?.ref || !selectedOrganization?.slug) return
// Optimistically update UI
setMessageRatings((prev) => ({ ...prev, [messageId]: rating }))
try {
const result = await rateMessage({
rating,
messages: chatMessages,
messageId,
projectRef: project.ref,
orgSlug: selectedOrganization.slug,
reason,
spanId: state.messageSpanIds[messageId],
})
track('assistant_message_rating_submitted', {
rating,
category: result.category,
...(reason && { reason }),
chatId,
})
} catch (error) {
console.error('Failed to rate message:', error)
// Rollback on error
setMessageRatings((prev) => {
const { [messageId]: _, ...rest } = prev
return rest
})
}
},
[chatMessages, project?.ref, selectedOrganization?.slug, rateMessage, track, state, chatId]
)
const isContextExceededError =
error &&
(error.message?.includes('context_length_exceeded') ||
error.message?.includes('exceeds the context window'))
const renderedMessages = useMemo(
() =>
chatMessages.map((message, index) => {
const isBeingEdited = editingMessageId === message.id
const isAfterEditedMessage = editingMessageId
? chatMessages.findIndex((m) => m.id === editingMessageId) < index
: false
const isLastMessage = index === chatMessages.length - 1
return (
<Fragment key={message.id}>
<Message
id={message.id}
message={message}
isLoading={chatStatus === 'submitted' || chatStatus === 'streaming'}
readOnly={message.role === 'user'}
addToolApprovalResponse={addToolApprovalResponse}
onDelete={deleteMessageFromHere}
onEdit={editMessage}
isAfterEditedMessage={isAfterEditedMessage}
isBeingEdited={isBeingEdited}
onCancelEdit={cancelEdit}
isLastMessage={isLastMessage}
onRate={handleRateMessage}
rating={messageRatings[message.id] ?? null}
onBranch={onBranchChat}
/>
{branchedConversation && branchedFrom?.messageId === message.id && (
<div className="flex items-center gap-2 mt-6">
<div className="flex-1 border-t border-strong" />
<div className="flex items-center gap-1 max-w-[80%] text-xs text-foreground-lighter">
<span className="shrink-0">Branched from</span>
<button
tabIndex={0}
className={cn(InlineLinkClassName, 'cursor-pointer truncate min-w-0')}
onClick={() => onSelectChat(branchedConversation.id)}
>
{branchedConversation.name}
</button>
</div>
<div className="flex-1 border-t border-strong" />
</div>
)}
</Fragment>
)
}),
[
chatMessages,
deleteMessageFromHere,
editMessage,
cancelEdit,
editingMessageId,
chatStatus,
addToolApprovalResponse,
handleRateMessage,
messageRatings,
branchedConversation,
branchedFrom,
onSelectChat,
onBranchChat,
]
)
const hasMessages = chatMessages.length > 0
const sendMessageToAssistant = (finalContent: string) => {
if (editingMessageId) {
// Handling when the user is in edit mode
// delete the message(s) from the chat just like the delete button
setIsResubmitting(true)
deleteMessageFromHere(editingMessageId)
setEditingMessageId(null)
}
// Read off the attachments this message actually carries, so detaching the
// "Current Query" chip also drops the claim. Gated on the same condition that
// decides whether attachments make it into the text at all: with AI opt-in
// disabled the chip is shown but no query is sent, and claiming otherwise would
// have the server prepend ClickHouse context for a message holding no query.
// Rides on the message rather than the request, so a Retry reproduces the context
// the message was asked in.
const metadata: AssistantMessageMetadata = {
containsLogsSnippets:
includeSnippetsInMessage && containsLogsSnippets(composerContext?.sqlSnippets),
}
const payload = {
role: 'user',
createdAt: new Date(),
parts: [{ type: 'text', text: finalContent }],
id: uuidv4(),
metadata,
} as MessageType
composerContext?.onClearSqlSnippets?.()
lastUserMessageRef.current = payload
if (hasPendingApproval && !editingMessageId) {
setMessages(resolvePendingToolApprovalsAsDenied(chatMessages))
}
sendMessage(payload, {
body: {
schema: currentSchema,
table: currentTable?.name,
},
})
setValue('')
if (finalContent.includes('Help me to debug')) {
track('assistant_debug_submitted', { chatId })
} else {
track('assistant_prompt_submitted', { chatId })
}
}
const handleClearMessages = () => {
if (isChatLoading) stop()
snap.clearMessages(chatId)
setMessages([])
lastUserMessageRef.current = null
setEditingMessageId(null)
}
useEffect(() => {
// Keep "Thinking" visible while stopping and resubmitting during edit
// Only clear once the new response actually starts streaming (or errors)
if (isResubmitting && (chatStatus === 'streaming' || !!error)) {
setIsResubmitting(false)
}
}, [isResubmitting, chatStatus, error])
useEffect(() => {
// Approval-required tools can't run in parallel. Auto-deny extras so the model reissues them sequentially.
for (const id of getParallelApprovalIdsToReject(chatMessages)) {
addToolApprovalResponse?.({
id,
approved: false,
reason:
'Only one approval-required tool call is allowed per turn. Please reissue this tool call after the current one completes.',
})
}
}, [chatMessages, addToolApprovalResponse])
useEffect(() => {
setValue(composerContext?.initialInput || '')
if (inputRef.current && composerContext?.initialInput) {
inputRef.current.focus()
inputRef.current.setSelectionRange(
composerContext.initialInput.length,
composerContext.initialInput.length
)
}
}, [composerContext?.initialInput])
let placeholder: string
if (hasMessages && isSupportChat) {
placeholder = 'Share details so the assistant can help with your support request...'
} else if (hasMessages) {
placeholder = 'Ask a follow up question...'
} else if ((composerContext?.sqlSnippets ?? []).length > 0) {
placeholder = 'Ask a question or make a change...'
} else if (isSupportChat) {
placeholder = 'Describe your support issue...'
} else {
placeholder = 'Chat to Postgres...'
}
return (
<ErrorBoundary
message="Something went wrong with the AI Assistant"
sentryContext={{
component: 'AIAssistant',
feature: 'AI Assistant Panel',
projectRef: project?.ref,
organizationSlug: selectedOrganization?.slug,
}}
actions={[
{
label: 'Clear messages and refresh',
onClick: () => {
handleClearMessages()
window.location.reload()
},
},
]}
>
<div className={cn('flex bg-card flex-col h-full w-full md:h-full max-h-dvh', className)}>
{renderHeader?.({
isChatLoading,
showMetadataWarning,
updatedOptInSinceMCP,
isHipaaProjectDisallowed,
aiOptInLevel,
})}
{hasMessages ? (
<Conversation className={cn('flex-1')}>
<ConversationContent className="w-full px-7 py-8 mb-10">
{renderedMessages}
<div className="w-full max-w-3xl mx-auto">
{error && (
<AlertError
error={
isContextExceededError
? ASSISTANT_ERRORS['context-exceeded']
: IS_PLATFORM
? ASSISTANT_ERRORS['default']
: error
}
showErrorPrefix={false}
showInstructions={false}
subject="Sorry, I'm having trouble responding right now."
additionalActions={
<div className="flex items-center gap-x-2 mr-auto">
{isContextExceededError ? (
<Button
variant="default"
size="tiny"
onClick={onNewChat}
className="text-xs"
>
New chat
</Button>
) : (
<>
<Button
variant="default"
size="tiny"
onClick={() => regenerate()}
className="text-xs"
>
Retry
</Button>
<ButtonTooltip
variant="default"
size="tiny"
onClick={handleClearMessages}
className="w-7 h-7"
icon={<Eraser />}
tooltip={{ content: { side: 'bottom', text: 'Clear messages' } }}
/>
</>
)}
</div>
}
/>
)}
{isChatLoading && (
<motion.span
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: [1, 0] }}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 1, repeat: Infinity, ease: 'linear' }
}
className="inline-block w-1.5 h-4 bg-foreground-lighter mt-4"
/>
)}
<p className="text-center text-xs text-foreground-muted mt-6">
The Assistant can make mistakes. Double check responses.
</p>
</div>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
) : (
<AIOnboarding
key={chatId}
sqlSnippets={composerContext?.sqlSnippets}
suggestions={composerContext?.suggestions}
onValueChange={(val) => setValue(val)}
onFocusInput={() => inputRef.current?.focus()}
/>
)}
<AnimatePresence>
{editingMessageId && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="pointer-events-none z-10 -mt-24"
>
<div className="h-24 w-full bg-linear-to-t from-background to-transparent relative">
<motion.div
className="absolute left-1/2 z-20 bottom-8 pointer-events-auto"
variants={{
hidden: { y: 5, opacity: 0 },
show: { y: 0, opacity: 1 },
}}
transition={{ duration: 0.1 }}
initial="hidden"
animate="show"
exit="hidden"
>
<div className="-translate-x-1/2 bg-alternative dark:bg-muted border rounded-md px-3 py-2 min-w-[180px] flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2 text-sm text-foreground">
<Pencil size={14} />
<span>Editing message</span>
</div>
<ButtonTooltip
variant="outline"
size="tiny"
icon={<X size={14} />}
onClick={cancelEdit}
className="w-6 h-6 p-0"
title="Cancel editing"
aria-label="Cancel editing"
tooltip={{
content: { side: 'top', text: <KeyboardShortcut keys={['Meta', 'Esc']} /> },
}}
/>
</div>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="relative z-20 w-full px-7 pb-3">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-y-3">
{isSupportChat && !isSupportChatClosed && (
<div>
<div className="mb-3 border-t" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="tiny"
disabled={!supportConversationId}
onClick={() => state.setSupportLifecycleStatus(chatId, 'escalated')}
>
Escalate to human
</Button>
<Button
variant="outline"
size="tiny"
disabled={!supportConversationId}
onClick={() => state.setSupportLifecycleStatus(chatId, 'user_resolved')}
>
Resolve
</Button>
</div>
</div>
)}
{disablePrompts && (
<Admonition
showIcon={false}
type="default"
title="Assistant has been temporarily disabled"
description="We're currently looking into getting it back online"
/>
)}
{isSuccess && !isApiKeySet && (
<Admonition
type="default"
title="OpenAI API key not set"
description={
<Markdown
content={
'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
}
/>
}
/>
)}
<AssistantChatForm
textAreaRef={inputRef}
className="z-20"
loading={isChatLoading}
isEditing={!!editingMessageId}
disabled={isChatInputDisabled}
placeholder={placeholder}
value={value}
onValueChange={(e) => {
setValue(e.target.value)
onInputChange?.(e.target.value)
}}
onSubmit={(finalMessage) => {
sendMessageToAssistant(finalMessage)
}}
onStop={() => {
stop()
// to save partial responses from the AI
const lastMessage = chatMessages[chatMessages.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
state.updateMessage(lastMessage, chatId)
}
}}
sqlSnippets={composerContext?.sqlSnippets}
onRemoveSnippet={(index) => {
const newSnippets = [...(composerContext?.sqlSnippets ?? [])]
newSnippets.splice(index, 1)
composerContext?.onSetSqlSnippets?.(newSnippets)
}}
includeSnippetsInMessage={includeSnippetsInMessage}
/>
</div>
</div>
</div>
</ErrorBoundary>
)
}