From 0e92a9574cc7389ee4fdfd67a44293e8b4885646 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 31 Jul 2026 14:38:02 +0800 Subject: [PATCH] Joshen/fe 3932 support branching conversations (#48519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Adds support for branching off from an Assistant's Response - which creates a new chat with all the previous messages including from where we're branching off from image Branched conversations will have an indication of where it was branched off from image ## Other changes - Also added support for copying an Assistant's Response image ## Summary by CodeRabbit * **New Features** * Branch conversations from assistant messages into a new chat. * View the originating conversation and navigate back to it. * Copy assistant message content with visual confirmation. * Access branching and copying actions from message controls. * **UI Updates** * Added “Branched from” indicators for branched conversations. * Updated the assistant disclaimer text to “The Assistant.” --- .../ui/AIAssistantPanel/AIAssistant.tsx | 61 +++++++++++++------ .../ui/AIAssistantPanel/Message.Actions.tsx | 38 +++++++++++- .../ui/AIAssistantPanel/Message.tsx | 20 ++++-- apps/studio/state/ai-assistant-state.tsx | 42 +++++++++++++ 4 files changed, 138 insertions(+), 23 deletions(-) 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