Files
Matt RossmanandJoshen Lim 4bb36b944f feat(studio): let High Compliance projects opt-in to Assistant data access (#50548)
Orgs with the HIPAA add-on had the Assistant's opt-in level forced to
`disabled` on any project marked High Compliance, regardless of what the
org picked in its AI settings. The restriction predated our AI provider
BAAs. The consequence is those users see the Assistant failing to answer
questions about their data w/ no clear path how to fix it, even though
the LLM provider supports this use case.

This PR removes these Assistant restrictions on the server and client so
those projects honor the org's chosen level. Braintrust conversation
tracing is unchanged and still blocked for these projects, see [this
test
case](https://github.com/supabase/supabase/blob/b9800ccf16/apps/studio/lib/ai/braintrust-logger.test.ts#L16-L20).
See
[comments](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-485a0d46)
for legal approval and conditions.

The client-side changes enable features like "Debug with AI" on SQL
query failures, “Generate/Rename with AI” for snippet titles, and
generated Assistant chat titles for these customers.

The AI opt-in copy now adds a reminder to obtain consent from data
subjects, linking the [shared responsibility
model](https://supabase.com/docs/guides/deployment/shared-responsibility-model)
based also on [this
comment](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-f81ee610).

<img width="400" alt="CleanShot 2026-09-17 at 5 01 02 PM@2x"
src="https://github.com/user-attachments/assets/d02123f2-3e32-4d83-9f98-7d15e59222ef"
/>

To test with a HIPAA-enabled project in staging, you can use this [Plan
Change
[Staging]](https://app.hex.tech/supabase/app/Plan-Change-Staging-032BD32jo1EaisCS85qunf/latest)
Hex to add the HIPAA add-on. Once the add-on is present, you can turn on
High Compliance from a project's settings. Also in org settings, crank
up the Assistant data opt-in level and verify the Assistant is able to
answer questions about the project's data.

My results testing with opt-in level "Schema, Logs & Database Data":

| High compliance setting | Data opt-in working |
|--------|--------|
| <img width="1302" height="422" alt="CleanShot 2026-09-17 at 5 03 36
PM@2x"
src="https://github.com/user-attachments/assets/c416371b-2eb8-49df-9c07-6d8eababb443"
/> | <img width="1566" height="1516" alt="CleanShot 2026-09-17 at 5 05
14 PM@2x"
src="https://github.com/user-attachments/assets/39624355-7f8f-46ce-9f08-a8acfb9da830"
/> |

Closes AI-1153


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

## New Features

- AI-assisted query renaming, snippet title generation, debugging, and
tools now follow organization AI opt-in settings rather than project
HIPAA status.
- Debugging assistance and AI actions remain available for eligible
users without additional HIPAA-based blocking.
- AI metadata warnings consistently show standard opt-in messaging and
permission settings.
- AI settings remind users to obtain consent before entering personal
data and link to shared responsibility guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-18 08:21:50 -04:00

690 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 { AssistantAgentHarnessFooter } from './AssistantAgentHarnessFooter'
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
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 } = 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 = 'Ask about your data, troubleshoot an issue, or explore your project...'
}
const composer = (
<div className={cn('relative z-20 w-full', hasMessages && '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.'
}
/>
}
/>
)}
<div>
<AssistantChatForm
textAreaRef={inputRef}
className={cn('z-20', !hasMessages && 'bg')}
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>
)
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,
aiOptInLevel,
})}
{hasMessages ? (
<Conversation className={cn('flex-1')}>
<ConversationContent className="w-full 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 size="tiny" onClick={onNewChat} className="text-xs">
New chat
</Button>
) : (
<>
<Button size="tiny" onClick={() => regenerate()} className="text-xs">
Retry
</Button>
<ButtonTooltip
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>
) : null}
<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>
{hasMessages ? (
composer
) : (
<AIOnboarding
key={chatId}
sqlSnippets={composerContext?.sqlSnippets}
suggestions={composerContext?.suggestions}
onValueChange={(prompt) => {
setValue(prompt)
onInputChange?.(prompt)
}}
onFocusInput={() => inputRef.current?.focus()}
>
{composer}
{!isSupportChat && <AssistantAgentHarnessFooter />}
</AIOnboarding>
)}
</div>
</ErrorBoundary>
)
}