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 { AssistantAgentHarnessFooter } from './AssistantAgentHarnessFooter' import { AssistantChatForm } from './AssistantChatForm' import { Conversation, ConversationContent, ConversationScrollButton, } from './elements/Conversation' import { Message } from './Message' import { groupMessageParts } from './Message.Parts.utils' 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 { useLatest } from '@/hooks/misc/useLatest' 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 { isTimedOutMessage, type AssistantMessageMetadata, } from '@/lib/ai/assistant-message-metadata' import { ASSISTANT_TIMEOUT_MESSAGE } from '@/lib/ai/assistant-timeout' 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 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(null) const { aiOptInLevel } = 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(null) const [value, setValue] = useState(composerContext?.initialInput || '') const [editingMessageId, setEditingMessageId] = useState(null) const [isResubmitting, setIsResubmitting] = useState(false) const [messageRatings, setMessageRatings] = useState>({}) 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({ id: chatId, // Batch token updates without throttling the SDK's tool execution or approval state. throttle: 50, ...(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 messagesRef = useLatest(chatMessages) const isChatLoadingRef = useLatest(isChatLoading) const branchedFrom = currentChat?.branchedFrom const branchedConversation = branchedFrom ? snap.chats[branchedFrom.chatId] : undefined const deleteMessageFromHere = useCallback( (messageId: string) => { const messages = messagesRef.current const messageIndex = messages.findIndex((msg) => msg.id === messageId) if (messageIndex === -1) return if (isChatLoadingRef.current) stop() state.deleteMessagesAfter(messageId, { includeSelf: true, chatId }) const updatedMessages = messages.slice(0, messageIndex) setMessages(updatedMessages) }, [state, setMessages, messagesRef, isChatLoadingRef, stop, chatId] ) const editMessage = useCallback( (messageId: string) => { const messages = messagesRef.current const messageIndex = messages.findIndex((msg) => msg.id === messageId) if (messageIndex === -1) return // Target message const messageToEdit = messages[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) }, [messagesRef, 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: messagesRef.current, 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 }) } }, [messagesRef, 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 lastMessage = chatMessages.at(-1) // A running tool group shimmers already, so the cursor would be a second loading indicator const isToolGroupRunning = isChatLoading && lastMessage?.role === 'assistant' && groupMessageParts(lastMessage.parts).at(-1)?.type === 'tool-group' const isTimedOut = !error && !isChatLoading && isTimedOutMessage(lastMessage) let displayError = IS_PLATFORM ? ASSISTANT_ERRORS['default'] : error if (isContextExceededError) displayError = ASSISTANT_ERRORS['context-exceeded'] if (isTimedOut) displayError = { message: ASSISTANT_TIMEOUT_MESSAGE } const editedMessageIndex = editingMessageId ? chatMessages.findIndex((message) => message.id === editingMessageId) : -1 const renderedMessages = useMemo( () => chatMessages.map((message, index) => { const isBeingEdited = editingMessageId === message.id const isAfterEditedMessage = !!editingMessageId && editedMessageIndex < index const isLastMessage = index === chatMessages.length - 1 return ( {branchedConversation && branchedFrom?.messageId === message.id && (
Branched from
)} ) }), [ chatMessages, deleteMessageFromHere, editMessage, cancelEdit, editingMessageId, editedMessageIndex, 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 = 'Ask about your data, troubleshoot an issue, or explore your project...' } const composer = (
{isSupportChat && !isSupportChatClosed && (
)} {disablePrompts && ( )} {isSuccess && !isApiKeySet && ( } /> )}
{ setValue(e.target.value) onInputChange?.(e.target.value) }} onSubmit={(finalMessage) => { sendMessageToAssistant(finalMessage) }} onStop={() => { stop() // to save partial responses from the AI // Read the live SDK state: the rendered snapshot may trail the stream by 50ms. const messages = chatInstance?.messages ?? chatMessages const lastMessage = messages[messages.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} />
) return ( { handleClearMessages() window.location.reload() }, }, ]} >
{renderHeader?.({ isChatLoading, showMetadataWarning, updatedOptInSinceMCP, aiOptInLevel, })} {hasMessages ? ( {renderedMessages}
{(error || isTimedOut) && ( {isContextExceededError ? ( ) : ( <> } tooltip={{ content: { side: 'bottom', text: 'Clear messages' } }} /> )}
} /> )} {isChatLoading && !isToolGroupRunning && ( )}

The Assistant can make mistakes. Double check responses.

) : null} {editingMessageId && (
Editing message
} onClick={cancelEdit} className="w-6 h-6 p-0" title="Cancel editing" aria-label="Cancel editing" tooltip={{ content: { side: 'top', text: }, }} />
)}
{hasMessages ? ( composer ) : ( { setValue(prompt) onInputChange?.(prompt) }} onFocusInput={() => inputRef.current?.focus()} > {composer} {!isSupportChat && } )}
) }