fix: imporove edit message with attachment image

This commit is contained in:
Faisal Amir 2025-09-15 21:48:01 +07:00
parent e80a865def
commit 3b22f0b7c0
3 changed files with 93 additions and 23 deletions

View File

@ -71,7 +71,7 @@ export const ThreadContent = memo(
streamTools?: any streamTools?: any
contextOverflowModal?: React.ReactNode | null contextOverflowModal?: React.ReactNode | null
updateMessage?: (item: ThreadMessage, message: string) => void updateMessage?: (item: ThreadMessage, message: string, imageUrls?: string[]) => void
} }
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -276,9 +276,10 @@ export const ThreadContent = memo(
item.content?.find((c) => c.type === 'text')?.text?.value || item.content?.find((c) => c.type === 'text')?.text?.value ||
'' ''
} }
onSave={(message) => { imageUrls={item.content?.filter((c) => c.type === 'image_url' && c.image_url?.url).map((c) => c.image_url!.url)}
onSave={(message, imageUrls) => {
if (item.updateMessage) { if (item.updateMessage) {
item.updateMessage(item, message) item.updateMessage(item, message, imageUrls)
} }
}} }}
/> />

View File

@ -11,7 +11,7 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { IconPencil } from '@tabler/icons-react' import { IconPencil, IconX } from '@tabler/icons-react'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -20,23 +20,27 @@ import {
interface EditMessageDialogProps { interface EditMessageDialogProps {
message: string message: string
onSave: (message: string) => void imageUrls?: string[]
onSave: (message: string, imageUrls?: string[]) => void
triggerElement?: React.ReactNode triggerElement?: React.ReactNode
} }
export function EditMessageDialog({ export function EditMessageDialog({
message, message,
imageUrls,
onSave, onSave,
triggerElement, triggerElement,
}: EditMessageDialogProps) { }: EditMessageDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [draft, setDraft] = useState(message) const [draft, setDraft] = useState(message)
const [keptImages, setKeptImages] = useState<string[]>(imageUrls || [])
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => { useEffect(() => {
setDraft(message) setDraft(message)
}, [message]) setKeptImages(imageUrls || [])
}, [message, imageUrls])
useEffect(() => { useEffect(() => {
if (isOpen && textareaRef.current) { if (isOpen && textareaRef.current) {
@ -48,8 +52,15 @@ export function EditMessageDialog({
}, [isOpen]) }, [isOpen])
const handleSave = () => { const handleSave = () => {
if (draft !== message && draft.trim()) { const hasTextChanged = draft !== message && draft.trim()
onSave(draft) const hasImageChanged =
JSON.stringify(imageUrls || []) !== JSON.stringify(keptImages)
if (hasTextChanged || hasImageChanged) {
onSave(
draft.trim() || message,
keptImages.length > 0 ? keptImages : undefined
)
setIsOpen(false) setIsOpen(false)
} }
} }
@ -64,7 +75,7 @@ export function EditMessageDialog({
const defaultTrigger = ( const defaultTrigger = (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative" className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
role="button" role="button"
tabIndex={0} tabIndex={0}
@ -90,6 +101,34 @@ export function EditMessageDialog({
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle> <DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
{keptImages.length > 0 && (
<div className="mt-2 space-y-2">
<div className="flex gap-3 flex-wrap">
{keptImages.map((imageUrl, index) => (
<div
key={index}
className="relative border border-main-view-fg/5 rounded-lg size-14"
>
<img
className="object-cover w-full h-full rounded-lg"
src={imageUrl}
alt={`Attached image ${index + 1}`}
/>
<div
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
onClick={() =>
setKeptImages((prev) =>
prev.filter((_, i) => i !== index)
)
}
>
<IconX className="text-destructive-fg" size={16} />
</div>
</div>
))}
</div>
</div>
)}
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={draft} value={draft}
@ -106,7 +145,12 @@ export function EditMessageDialog({
</Button> </Button>
</DialogClose> </DialogClose>
<Button <Button
disabled={draft === message || !draft.trim()} disabled={
draft === message &&
JSON.stringify(imageUrls || []) ===
JSON.stringify(keptImages) &&
!draft.trim()
}
onClick={handleSave} onClick={handleSave}
size="sm" size="sm"
className="w-full sm:w-auto" className="w-full sm:w-auto"

View File

@ -87,12 +87,15 @@ function ThreadDetail() {
}, [threadId, currentThreadId, assistants]) }, [threadId, currentThreadId, assistants])
useEffect(() => { useEffect(() => {
serviceHub.messages().fetchMessages(threadId).then((fetchedMessages) => { serviceHub
if (fetchedMessages) { .messages()
// Update the messages in the store .fetchMessages(threadId)
setMessages(threadId, fetchedMessages) .then((fetchedMessages) => {
} if (fetchedMessages) {
}) // Update the messages in the store
setMessages(threadId, fetchedMessages)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId, serviceHub]) }, [threadId, serviceHub])
@ -137,17 +140,21 @@ function ThreadDetail() {
useEffect(() => { useEffect(() => {
// Track streaming state changes // Track streaming state changes
const isCurrentlyStreaming = !!streamingContent const isCurrentlyStreaming = !!streamingContent
const justFinishedStreaming = wasStreamingRef.current && !isCurrentlyStreaming const justFinishedStreaming =
wasStreamingRef.current && !isCurrentlyStreaming
wasStreamingRef.current = isCurrentlyStreaming wasStreamingRef.current = isCurrentlyStreaming
// If streaming just finished and user had an intended position, restore it // If streaming just finished and user had an intended position, restore it
if (justFinishedStreaming && userIntendedPositionRef.current !== null) { if (justFinishedStreaming && userIntendedPositionRef.current !== null) {
// Small delay to ensure DOM has updated // Small delay to ensure DOM has updated
setTimeout(() => { setTimeout(() => {
if (scrollContainerRef.current && userIntendedPositionRef.current !== null) { if (
scrollContainerRef.current &&
userIntendedPositionRef.current !== null
) {
scrollContainerRef.current.scrollTo({ scrollContainerRef.current.scrollTo({
top: userIntendedPositionRef.current, top: userIntendedPositionRef.current,
behavior: 'smooth' behavior: 'smooth',
}) })
userIntendedPositionRef.current = null userIntendedPositionRef.current = null
setIsUserScrolling(false) setIsUserScrolling(false)
@ -196,7 +203,7 @@ function ThreadDetail() {
// Detect if this is a user-initiated scroll // Detect if this is a user-initiated scroll
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) { if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
setIsUserScrolling(!isBottom) setIsUserScrolling(!isBottom)
// If user scrolls during streaming and moves away from bottom, record their intended position // If user scrolls during streaming and moves away from bottom, record their intended position
if (streamingContent && !isBottom) { if (streamingContent && !isBottom) {
userIntendedPositionRef.current = scrollTop userIntendedPositionRef.current = scrollTop
@ -218,7 +225,7 @@ function ThreadDetail() {
// Detect if this is a user-initiated scroll // Detect if this is a user-initiated scroll
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) { if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
setIsUserScrolling(!isBottom) setIsUserScrolling(!isBottom)
// If user scrolls during streaming and moves away from bottom, record their intended position // If user scrolls during streaming and moves away from bottom, record their intended position
if (streamingContent && !isBottom) { if (streamingContent && !isBottom) {
userIntendedPositionRef.current = scrollTop userIntendedPositionRef.current = scrollTop
@ -229,11 +236,15 @@ function ThreadDetail() {
lastScrollTopRef.current = scrollTop lastScrollTopRef.current = scrollTop
} }
const updateMessage = (item: ThreadMessage, message: string) => { const updateMessage = (
item: ThreadMessage,
message: string,
imageUrls?: string[]
) => {
const newMessages: ThreadMessage[] = messages.map((m) => { const newMessages: ThreadMessage[] = messages.map((m) => {
if (m.id === item.id) { if (m.id === item.id) {
const msg: ThreadMessage = cloneDeep(m) const msg: ThreadMessage = cloneDeep(m)
msg.content = [ const newContent = [
{ {
type: ContentType.Text, type: ContentType.Text,
text: { text: {
@ -242,6 +253,20 @@ function ThreadDetail() {
}, },
}, },
] ]
// Add image content if imageUrls are provided
if (imageUrls && imageUrls.length > 0) {
imageUrls.forEach((url) => {
newContent.push({
type: 'image_url' as ContentType,
image_url: {
url: url,
},
} as any)
})
}
msg.content = newContent
return msg return msg
} }
return m return m