diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
index aafba624baf..ca13118c5bc 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
@@ -6,13 +6,14 @@ import { useParams, useSearchParamsShallow } from 'common/hooks'
import { AnimatePresence, motion } from 'framer-motion'
import { Eraser, Pencil, X } from 'lucide-react'
import { useRouter } from 'next/router'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } 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 type { SqlSnippet } from './AIAssistant.types'
import {
@@ -186,6 +187,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const isChatInputDisabled =
!isApiKeySet || disablePrompts || isLoadingOrganization || isSupportChatClosed
+ const branchedFrom = snap.activeChat?.branchedFrom
+ const branchedConversation = branchedFrom ? snap.chats[branchedFrom.chatId] : undefined
+
const deleteMessageFromHere = useCallback(
(messageId: string) => {
// Find the message index in current chatMessages
@@ -289,22 +293,39 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const isLastMessage = index === chatMessages.length - 1
return (
-
+
+
+ {branchedConversation && branchedFrom?.messageId === message.id && (
+
+
+
+ Branched from
+
+
+
+
+ )}
+
)
}),
[
@@ -317,6 +338,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
addToolApprovalResponse,
handleRateMessage,
messageRatings,
+ branchedConversation,
+ branchedFrom,
+ snap,
]
)
@@ -491,8 +515,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
className="inline-block w-1.5 h-4 bg-foreground-lighter mt-4"
/>
)}
+
- Supabase AI may not always produce correct answers. Double check responses.
+ The Assistant can make mistakes. Double check responses.
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
index d7695343d70..e0f05f01e77 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
-import { Pencil, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react'
+import { Check, Copy, Pencil, Split, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react'
import { useEffect, useState, type PropsWithChildren } from 'react'
import { useForm } from 'react-hook-form'
import {
@@ -65,6 +65,42 @@ function MessageActionsDelete({ onClick }: { onClick: () => void }) {
}
MessageActions.Delete = MessageActionsDelete
+function MessageActionsBranch({ onClick }: { onClick: () => void }) {
+ return (
+ }
+ onClick={onClick}
+ className="text-foreground-light hover:text-foreground p-1 rounded-sm"
+ title="Branch in new chat"
+ aria-label="Branch in new chat"
+ tooltip={{ content: { side: 'bottom', text: 'Branch in new chat' } }}
+ />
+ )
+}
+MessageActions.Branch = MessageActionsBranch
+
+function MessageActionsCopy({ onClick }: { onClick: (onSuccess: () => void) => void }) {
+ const [copied, setCopied] = useState(false)
+
+ useEffect(() => {
+ if (copied) setTimeout(() => setCopied(false), 1000)
+ }, [copied])
+
+ return (
+ : }
+ onClick={() => onClick(() => setCopied(true))}
+ className="text-foreground-light hover:text-foreground p-1 rounded-sm"
+ title="Copy response"
+ aria-label="Copy response"
+ tooltip={{ content: { side: 'bottom', text: 'Copy response' } }}
+ />
+ )
+}
+MessageActions.Copy = MessageActionsCopy
+
function MessageActionsThumbsUp({
onClick,
isActive,
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
index 105f53a3b37..c9a5ef5e6e5 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
@@ -1,22 +1,32 @@
import { UIMessage as VercelMessage } from '@ai-sdk/react'
import { useState } from 'react'
import { toast } from 'sonner'
-import { cn } from 'ui'
+import { cn, copyToClipboard } from 'ui'
import { DeleteMessageConfirmModal } from './DeleteMessageConfirmModal'
import { MessageActions } from './Message.Actions'
import type { AddToolApprovalResponse, MessageInfo } from './Message.Context'
import { MessageProvider, useMessageActionsContext, useMessageInfoContext } from './Message.Context'
import { MessageDisplay } from './Message.Display'
+import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
function AssistantMessage({ message }: { message: VercelMessage }) {
- const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext()
+ const snap = useAiAssistantStateSnapshot()
const { onCancelEdit, onRate } = useMessageActionsContext()
+ const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext()
const handleRate = (newRating: 'positive' | 'negative', reason?: string) => {
onRate?.(id, newRating, reason)
}
+ const handleCopy = (onSuccess: () => void) => {
+ const response = message.parts
+ .filter((x) => x.type === 'text')
+ .map((x) => x.text)
+ .join('\n')
+ copyToClipboard(response, onSuccess)
+ }
+
return (
- {!readOnly && isLastMessage && onRate && !isLoading && (
-
+ {!readOnly && onRate && !isLoading && (
+
+
handleRate('positive')}
isActive={rating === 'positive'}
@@ -40,6 +51,7 @@ function AssistantMessage({ message }: { message: VercelMessage }) {
isActive={rating === 'negative'}
disabled={!!rating}
/>
+ snap.branchChat(id)} />
)}
diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx
index fc9d188f969..9e51958f1ab 100644
--- a/apps/studio/state/ai-assistant-state.tsx
+++ b/apps/studio/state/ai-assistant-state.tsx
@@ -61,6 +61,7 @@ type ChatSession = {
createdAt: Date
updatedAt: Date
supportMetadata?: SupportChatMetadata
+ branchedFrom?: { chatId: string; messageId: string }
}
export type AiAssistantContext = {
@@ -424,6 +425,46 @@ export const createAiAssistantState = (): AiAssistantState => {
return chatId
},
+ branchChat: (messageId: string) => {
+ const sourceChat = state.activeChat
+ if (!sourceChat) return
+
+ const messageIndex = sourceChat.messages.findIndex((msg) => msg.id === messageId)
+ if (messageIndex === -1) return
+
+ const branchedMessages = sourceChat.messages
+ .slice(0, messageIndex + 1)
+ .map((message) => sanitizeForCloning(message))
+
+ const chatId = uuidv4()
+ const newChat: ChatSession = {
+ id: chatId,
+ name: `Branch - ${sourceChat.name}`,
+ messages: branchedMessages,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ branchedFrom: { chatId: sourceChat.id, messageId },
+ }
+
+ state.chats = {
+ ...state.chats,
+ [chatId]: newChat,
+ }
+ state.activeChatId = chatId
+
+ state.chatInstances[chatId] = ref(
+ createChatInstance(state, { id: chatId, initialMessages: branchedMessages })
+ )
+
+ const initialAiAssistantData = createInitialAiAssistantData()
+ state.initialInput = initialAiAssistantData.initialInput
+ state.sqlSnippets = initialAiAssistantData.sqlSnippets
+ state.suggestions = initialAiAssistantData.suggestions
+ state.tables = initialAiAssistantData.tables
+
+ return chatId
+ },
+
setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => {
const chat = state.chats[chatId]
if (!chat?.supportMetadata) return
@@ -608,6 +649,7 @@ export type AiAssistantState = AiAssistantData & {
Pick
>
) => string
+ branchChat: (messageId: string) => string | undefined
setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => void
selectChat: (id: string) => void
deleteChat: (id: string) => void