Joshen/fe 3932 support branching conversations (#48519)

## 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
<img width="204" height="97" alt="image"
src="https://github.com/user-attachments/assets/0b171ae6-f2b4-4b58-87fa-0010ad45f777"
/>

Branched conversations will have an indication of where it was branched
off from
<img width="404" height="427" alt="image"
src="https://github.com/user-attachments/assets/bed8502f-3f83-4f76-bc00-feac86fa57a6"
/>


## Other changes
- Also added support for copying an Assistant's Response
<img width="190" height="115" alt="image"
src="https://github.com/user-attachments/assets/5e4aa0b8-eb6e-485f-80c0-3028b95720f7"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.”
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-07-31 13:38:02 +07:00
1 parent fc5e03f9e3
commit 0e92a9574c
4 files changed
+138 -23

No files matched your search

@@ -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 (
<Message
id={message.id}
key={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}
/>
<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}
/>
{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={() => snap.selectChat(branchedConversation.id)}
>
{branchedConversation.name}
</button>
</div>
<div className="flex-1 border-t border-strong" />
</div>
)}
</Fragment>
)
}),
[
@@ -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"
/>
)}
<p className="text-center text-xs text-foreground-muted mt-6">
Supabase AI may not always produce correct answers. Double check responses.
The Assistant can make mistakes. Double check responses.
</p>
</ConversationContent>
<ConversationScrollButton />
@@ -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 (
<ButtonTooltip
variant="text"
icon={<Split size={14} strokeWidth={1.5} />}
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 (
<ButtonTooltip
variant="text"
icon={copied ? <Check size={14} strokeWidth={2} /> : <Copy size={14} strokeWidth={1.5} />}
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,
@@ -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 (
<MessageDisplay.Container
className={cn(
@@ -28,8 +38,9 @@ function AssistantMessage({ message }: { message: VercelMessage }) {
<MessageDisplay.MainArea>
<MessageDisplay.Content message={message} />
</MessageDisplay.MainArea>
{!readOnly && isLastMessage && onRate && !isLoading && (
<MessageActions alwaysShow>
{!readOnly && onRate && !isLoading && (
<MessageActions alwaysShow={isLastMessage}>
<MessageActions.Copy onClick={handleCopy} />
<MessageActions.ThumbsUp
onClick={() => handleRate('positive')}
isActive={rating === 'positive'}
@@ -40,6 +51,7 @@ function AssistantMessage({ message }: { message: VercelMessage }) {
isActive={rating === 'negative'}
disabled={!!rating}
/>
<MessageActions.Branch onClick={() => snap.branchChat(id)} />
</MessageActions>
)}
</MessageDisplay.Container>
+42
View File
@@ -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<AiAssistantData, 'initialInput' | 'sqlSnippets' | 'suggestions' | 'tables'>
>
) => string
branchChat: (messageId: string) => string | undefined
setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => void
selectChat: (id: string) => void
deleteChat: (id: string) => void