Skip to content

Commit a8f6d85

Browse files
Add message deletion functionality to AI Assistant (supabase#37698)
* add delete chat message functionality * Implement message editing functionality in AIAssistant * add functionality to delete messages in AI Assistant chat in assistant state * remove duplicated `text-xs` class * add a 'group' class for improved styling and refining action button visibility on hover * Refine action button visibility in AIAssistantPanel to show only for user messages on hover, improving user experience. * remove `"` from the message to edit * Enhance editing message display in AIAssistantPanel by updating styling for improved visibility and user experience. * Refactor AIAssistantPanel state management by simplifying original message content initialization and clarifying comments for better code readability. * clean up action button rendering in AIAssistantPanel message component * adjust the chat message layout (space) * simplify the editing mode message * show the edit button even when user is already in the edit mode * Update AssistantChatForm styling for editing mode, positioning the editing message above the input field without changing the layout * use `ButtonTooltip` for edit button to show tooltip * simplify the condiiton to show the edit buton * add the delete action button * Refactor action button rendering in AIAssistantPanel to improve clarity and maintainability. Ensure buttons are only shown for user messages on hover, and simplify the conditional rendering logic. * Remove commented-out code for cleaner implementation. * improve unused onDeleteAfter prop and consolidating delete logic into a single `deleteMessageFromHere` function for improved clarity * abort streaming when a user deletes a message * delete unnecessary `originalMessageContent` state variable * delete `confirmEdit` and move its logic into `sendMessageToAssistant` * add `strokeWidth={1.5}` to `Pencil` icon * add `opacity-50` to any messages that are after the message being edited, and it cancels the edit when those messages are clicked * add a pulse animation to the user’s message being edited * add more space between mesages and `AssistantChatForm` * add framer-motion animation to `Editing message` element * update strokeWidth of Trash2 icon in Message component for better visibility * refactor Message component to make onDelete prop required * add `DeleteMessageConfirmModal` for message deletion confirmation in Message component * refactor message deletion logic in AIAssistant component to streamline editing process * cancel editing when a user clicks the edit button again or the edit button in subsequent messages * Add success toast notification for message deletion * add Pencil icon to AIAssistantPanel component and fixed duplicated tailwind class * Update AIAssistantPanel to use the Pencil icon * Refactor AIAssistant to set editing message content from text parts * Remove unused `reload` prop from AIAssistant component * `setIsSticky(false)` when a user deletes all the messages or clear the chat to remove the `ArrowDown` icon scroll down button * refactor: remove deleteMessagesBefore function from AiAssistantState * refactor: simplify AssistantChatForm layout by removing unnecessary `z-index` * enable the input field when a user wants to edit the message while the response is streaming * Smol fixes * remove conosle log * Add state for resubmitting in AI Assistant panel * Remove classes that hide the focus styles + fix focusing when editing message to ensure cursor is at the end of the text --------- Co-authored-by: Joshen Lim <[email protected]>
1 parent aea43f6 commit a8f6d85

File tree

5 files changed

+168
-36
lines changed

5 files changed

+168
-36
lines changed

apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const MemoizedMessage = memo(
4242
message,
4343
isLoading,
4444
onResults,
45+
onDelete,
4546
onEdit,
4647
isAfterEditedMessage,
4748
isBeingEdited,
@@ -58,6 +59,7 @@ const MemoizedMessage = memo(
5859
resultId?: string
5960
results: any[]
6061
}) => void
62+
onDelete: (id: string) => void
6163
onEdit: (id: string) => void
6264
isAfterEditedMessage: boolean
6365
isBeingEdited: boolean
@@ -70,6 +72,7 @@ const MemoizedMessage = memo(
7072
readOnly={message.role === 'user'}
7173
isLoading={isLoading}
7274
onResults={onResults}
75+
onDelete={onDelete}
7376
onEdit={onEdit}
7477
isAfterEditedMessage={isAfterEditedMessage}
7578
isBeingEdited={isBeingEdited}
@@ -105,7 +108,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
105108
)
106109

107110
const inputRef = useRef<HTMLTextAreaElement>(null)
108-
const { ref: scrollContainerRef, isSticky, scrollToEnd } = useAutoScroll()
111+
const { ref: scrollContainerRef, isSticky, scrollToEnd, setIsSticky } = useAutoScroll()
109112

110113
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
111114
const showMetadataWarning =
@@ -119,6 +122,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
119122
const [value, setValue] = useState<string>(snap.initialInput || '')
120123
const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false)
121124
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
125+
const [isResubmitting, setIsResubmitting] = useState(false)
122126

123127
const { data: check, isSuccess } = useCheckOpenAIKeyQuery()
124128
const isApiKeySet = IS_PLATFORM || !!check?.hasKey
@@ -232,7 +236,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
232236
onFinish: handleChatFinish,
233237
})
234238

235-
const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming'
239+
const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming' || isResubmitting
236240

237241
const updateMessage = useCallback(
238242
({
@@ -249,6 +253,22 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
249253
[snap]
250254
)
251255

256+
const deleteMessageFromHere = useCallback(
257+
(messageId: string) => {
258+
// Find the message index in current chatMessages
259+
const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
260+
if (messageIndex === -1) return
261+
262+
if (isChatLoading) stop()
263+
264+
snap.deleteMessagesAfter(messageId, { includeSelf: true })
265+
266+
const updatedMessages = chatMessages.slice(0, messageIndex)
267+
setMessages(updatedMessages)
268+
},
269+
[snap, setMessages, chatMessages, isChatLoading, stop]
270+
)
271+
252272
const editMessage = useCallback(
253273
(messageId: string) => {
254274
const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
@@ -266,9 +286,16 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
266286
.join('') ?? ''
267287
setValue(textContent)
268288

269-
if (inputRef.current) {
270-
inputRef.current.focus()
271-
}
289+
setTimeout(() => {
290+
if (inputRef.current) {
291+
inputRef?.current?.focus()
292+
293+
// [Joshen] This is just to make the cursor go to the end of the text when focusing
294+
const val = inputRef.current.value
295+
inputRef.current.value = ''
296+
inputRef.current.value = val
297+
}
298+
}, 100)
272299
},
273300
[chatMessages, setValue]
274301
)
@@ -292,14 +319,23 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
292319
message={message}
293320
isLoading={isChatLoading && message.id === chatMessages[chatMessages.length - 1].id}
294321
onResults={updateMessage}
322+
onDelete={deleteMessageFromHere}
295323
onEdit={editMessage}
296324
isAfterEditedMessage={isAfterEditedMessage}
297325
isBeingEdited={isBeingEdited}
298326
onCancelEdit={cancelEdit}
299327
/>
300328
)
301329
}),
302-
[chatMessages, isChatLoading, updateMessage, editMessage, editingMessageId, cancelEdit]
330+
[
331+
chatMessages,
332+
isChatLoading,
333+
updateMessage,
334+
deleteMessageFromHere,
335+
editMessage,
336+
cancelEdit,
337+
editingMessageId,
338+
]
303339
)
304340

305341
const hasMessages = chatMessages.length > 0
@@ -308,12 +344,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
308344
const sendMessageToAssistant = (finalContent: string) => {
309345
if (editingMessageId) {
310346
// Handling when the user is in edit mode
311-
const messageIndex = chatMessages.findIndex((msg) => msg.id === editingMessageId)
312-
if (messageIndex === -1) return
313-
314-
snap.deleteMessagesAfter(editingMessageId, { includeSelf: true })
315-
const updatedMessages = chatMessages.slice(0, messageIndex)
316-
setMessages(updatedMessages)
347+
// delete the message(s) from the chat just like the delete button
348+
setIsResubmitting(true)
349+
deleteMessageFromHere(editingMessageId)
317350
setEditingMessageId(null)
318351
}
319352

@@ -355,6 +388,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
355388
setEditingMessageId(null)
356389
}
357390

391+
useEffect(() => {
392+
// Keep "Thinking" visible while stopping and resubmitting during edit
393+
// Only clear once the new response actually starts streaming (or errors)
394+
if (isResubmitting && (chatStatus === 'streaming' || !!error)) {
395+
setIsResubmitting(false)
396+
}
397+
}, [isResubmitting, chatStatus, error])
398+
358399
// Update scroll behavior for new messages
359400
useEffect(() => {
360401
if (!isChatLoading) {
@@ -381,6 +422,10 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
381422
// eslint-disable-next-line react-hooks/exhaustive-deps
382423
}, [snap.open, isInSQLEditor, snippetContent])
383424

425+
useEffect(() => {
426+
if (chatMessages.length === 0 && !isSticky) setIsSticky(true)
427+
}, [chatMessages, isSticky, setIsSticky])
428+
384429
return (
385430
<ErrorBoundary
386431
message="Something went wrong with the AI Assistant"
@@ -699,7 +744,8 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
699744
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
700745
)}
701746
loading={isChatLoading}
702-
disabled={!isApiKeySet || disablePrompts || isChatLoading}
747+
isEditing={!!editingMessageId}
748+
disabled={!isApiKeySet || disablePrompts || (isChatLoading && !editingMessageId)}
703749
placeholder={
704750
hasMessages
705751
? 'Ask a follow up question...'

apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface FormProps {
4141
snippetsClassName?: string
4242
/* Additional class name for the form wrapper */
4343
className?: string
44+
/* If currently editing an existing message */
45+
isEditing?: boolean
4446
}
4547

4648
const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
@@ -59,6 +61,7 @@ const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
5961
snippetsClassName,
6062
includeSnippetsInMessage = false,
6163
className,
64+
isEditing = false,
6265
...props
6366
},
6467
ref
@@ -68,7 +71,7 @@ const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
6871

6972
const handleSubmit = (event?: FormEvent<HTMLFormElement>) => {
7073
if (event) event.preventDefault()
71-
if (!value || loading) return
74+
if (!value || (loading && !isEditing)) return
7275

7376
let finalMessage = value
7477
if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) {
@@ -107,8 +110,8 @@ const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
107110
/>
108111
)}
109112
<ExpandingTextArea
113+
autoFocus={!isMobile}
110114
ref={textAreaRef}
111-
autoFocus={isMobile}
112115
disabled={disabled}
113116
className={cn(
114117
'text-sm pr-10 max-h-64',
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
Button,
3+
Dialog,
4+
DialogContent,
5+
DialogFooter,
6+
DialogHeader,
7+
DialogSection,
8+
DialogSectionSeparator,
9+
DialogTitle,
10+
} from 'ui'
11+
12+
type DeleteMessageConfirmModalProps = {
13+
visible: boolean
14+
onConfirm: () => void
15+
onCancel: () => void
16+
}
17+
18+
export const DeleteMessageConfirmModal = ({
19+
visible,
20+
onConfirm,
21+
onCancel,
22+
}: DeleteMessageConfirmModalProps) => {
23+
const onOpenChange = (open: boolean) => {
24+
if (!open) onCancel()
25+
}
26+
27+
return (
28+
<Dialog open={visible} onOpenChange={onOpenChange}>
29+
<DialogContent size="small">
30+
<DialogHeader padding="small">
31+
<DialogTitle>Delete Message</DialogTitle>
32+
</DialogHeader>
33+
34+
<DialogSectionSeparator />
35+
36+
<DialogSection padding="small">
37+
<p className="text-sm text-foreground-light">
38+
Are you sure you want to delete this message and all subsequent messages? This action
39+
cannot be undone.
40+
</p>
41+
</DialogSection>
42+
43+
<DialogFooter padding="small">
44+
<Button type="default" onClick={onCancel}>
45+
Cancel
46+
</Button>
47+
<Button type="danger" onClick={onConfirm}>
48+
Delete
49+
</Button>
50+
</DialogFooter>
51+
</DialogContent>
52+
</Dialog>
53+
)
54+
}

apps/studio/components/ui/AIAssistantPanel/Message.tsx

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { UIMessage as VercelMessage } from '@ai-sdk/react'
2-
import { Loader2, Pencil } from 'lucide-react'
3-
import { createContext, PropsWithChildren, ReactNode, useMemo } from 'react'
2+
import { Loader2, Pencil, Trash2 } from 'lucide-react'
3+
import { createContext, PropsWithChildren, ReactNode, useMemo, useState } from 'react'
44
import ReactMarkdown from 'react-markdown'
55
import { Components } from 'react-markdown/lib/ast-to-react'
66
import remarkGfm from 'remark-gfm'
7+
import { toast } from 'sonner'
78

89
import { ProfileImage } from 'components/ui/ProfileImage'
910
import { useProfile } from 'lib/profile'
1011
import { cn, markdownComponents, WarningIcon } from 'ui'
1112
import { ButtonTooltip } from '../ButtonTooltip'
1213
import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock'
1314
import { DisplayBlockRenderer } from './DisplayBlockRenderer'
15+
import { DeleteMessageConfirmModal } from './DeleteMessageConfirmModal'
1416
import {
1517
Heading3,
1618
Hyperlink,
@@ -51,6 +53,7 @@ interface MessageProps {
5153
resultId?: string
5254
results: any[]
5355
}) => void
56+
onDelete: (id: string) => void
5457
onEdit: (id: string) => void
5558
isAfterEditedMessage: boolean
5659
isBeingEdited: boolean
@@ -65,12 +68,14 @@ export const Message = function Message({
6568
action = null,
6669
variant = 'default',
6770
onResults,
71+
onDelete,
6872
onEdit,
6973
isAfterEditedMessage = false,
7074
isBeingEdited = false,
7175
onCancelEdit,
7276
}: PropsWithChildren<MessageProps>) {
7377
const { profile } = useProfile()
78+
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
7479
const allMarkdownComponents: Partial<Components> = useMemo(
7580
() => ({
7681
...markdownComponents,
@@ -237,30 +242,54 @@ export const Message = function Message({
237242
<span className="text-foreground-lighter italic">Assistant is thinking...</span>
238243
)}
239244

240-
{/* Action button - only show for user messages on hover */}
241-
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
245+
{/* Action buttons - only show for user messages on hover */}
246+
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
242247
{message.role === 'user' && (
243-
<ButtonTooltip
244-
type="text"
245-
icon={<Pencil size={14} strokeWidth={1.5} />}
246-
onClick={isBeingEdited || isAfterEditedMessage ? onCancelEdit : () => onEdit(id)}
247-
className="text-foreground-light hover:text-foreground p-1 rounded"
248-
aria-label={
249-
isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message'
250-
}
251-
tooltip={{
252-
content: {
253-
side: 'bottom',
254-
text:
255-
isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message',
256-
},
257-
}}
258-
/>
248+
<>
249+
<ButtonTooltip
250+
type="text"
251+
icon={<Pencil size={14} strokeWidth={1.5} />}
252+
onClick={
253+
isBeingEdited || isAfterEditedMessage ? onCancelEdit : () => onEdit(id)
254+
}
255+
className="text-foreground-light hover:text-foreground p-1 rounded"
256+
aria-label={
257+
isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message'
258+
}
259+
tooltip={{
260+
content: {
261+
side: 'bottom',
262+
text:
263+
isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message',
264+
},
265+
}}
266+
/>
267+
268+
<ButtonTooltip
269+
type="text"
270+
icon={<Trash2 size={14} strokeWidth={1.5} />}
271+
tooltip={{ content: { side: 'bottom', text: 'Delete message' } }}
272+
onClick={() => setShowDeleteConfirmModal(true)}
273+
className="text-foreground-light hover:text-foreground p-1 rounded"
274+
title="Delete message"
275+
aria-label="Delete message"
276+
/>
277+
</>
259278
)}
260279
</div>
261280
</div>
262281
</div>
263282
</div>
283+
284+
<DeleteMessageConfirmModal
285+
visible={showDeleteConfirmModal}
286+
onConfirm={() => {
287+
onDelete(id)
288+
setShowDeleteConfirmModal(false)
289+
toast.success('Message deleted successfully')
290+
}}
291+
onCancel={() => setShowDeleteConfirmModal(false)}
292+
/>
264293
</MessageContext.Provider>
265294
)
266295
}

apps/studio/components/ui/AIAssistantPanel/hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,5 @@ export function useAutoScroll({ enabled = true }: UseAutoScrollProps = {}) {
6969
}
7070
}, [container, enabled, scrollToEnd])
7171

72-
return { ref, isSticky, scrollToEnd }
72+
return { ref, isSticky, scrollToEnd, setIsSticky }
7373
}

0 commit comments

Comments
 (0)