Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 52 additions & 13 deletions web-app/src/containers/MessageItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { memo, useState, useCallback } from 'react'

Check warning on line 2 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

2 line is not covered with tests
import type { UIMessage, ChatStatus } from 'ai'
import { RenderMarkdown } from './RenderMarkdown'
import { cn } from '@/lib/utils'
Expand All @@ -9,31 +9,33 @@
ReasoningContent,
ReasoningTrigger,
} from '@/ai-elements/reasoning'
import {

Check warning on line 12 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

12 line is not covered with tests
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from '@/ai-elements/tool'
import { CopyButton } from './CopyButton'
import { AvatarEmoji } from '@/containers/AvatarEmoji'
import { useModelProvider } from '@/hooks/useModelProvider'
import { IconRefresh, IconPaperclip } from '@tabler/icons-react'
import { EditMessageDialog } from '@/containers/dialogs/EditMessageDialog'
import { DeleteMessageDialog } from '@/containers/dialogs/DeleteMessageDialog'
import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator'
import { extractFilesFromPrompt, FileMetadata } from '@/lib/fileMetadata'
import { useMemo } from 'react'

Check warning on line 27 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

19-27 lines are not covered with tests

const CHAT_STATUS = {
STREAMING: 'streaming',
SUBMITTED: 'submitted',
} as const

Check warning on line 32 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

29-32 lines are not covered with tests

const CONTENT_TYPE = {
TEXT: 'text',
FILE: 'file',
REASONING: 'reasoning',
} as const

Check warning on line 38 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

34-38 lines are not covered with tests

export type MessageItemProps = {
message: UIMessage
Expand All @@ -42,30 +44,56 @@
status: ChatStatus
reasoningContainerRef?: React.RefObject<HTMLDivElement | null>
onRegenerate?: (messageId: string) => void
onEdit?: (messageId: string, newText: string) => void
onDelete?: (messageId: string) => void
assistant?: { avatar?: React.ReactNode; name?: string }
showAssistant?: boolean
}

export const MessageItem = memo(
({
message,
isLastMessage,
status,
reasoningContainerRef,
onRegenerate,
onEdit,
onDelete,
assistant,
showAssistant,
}: MessageItemProps) => {
const selectedModel = useModelProvider((state) => state.selectedModel)
const [previewImage, setPreviewImage] = useState<{

Check warning on line 66 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

53-66 lines are not covered with tests
url: string
filename?: string
} | null>(null)

Check warning on line 69 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

69 line is not covered with tests

const handleRegenerate = useCallback(() => {
onRegenerate?.(message.id)
}, [onRegenerate, message.id])

Check warning on line 73 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

71-73 lines are not covered with tests

const handleEdit = useCallback(
(newText: string) => {
onEdit?.(message.id, newText)
},
[onEdit, message.id]
)

Check warning on line 80 in web-app/src/containers/MessageItem.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

75-80 lines are not covered with tests

const handleDelete = useCallback(() => {
onDelete?.(message.id)
}, [onDelete, message.id])

// Get image URLs from file parts for the edit dialog
const imageUrls = useMemo(() => {
return message.parts
.filter((part) => {
if (part.type !== 'file') return false
const filePart = part as { type: 'file'; url?: string; mediaType?: string }
return filePart.url && filePart.mediaType?.startsWith('image/')
})
.map((part) => (part as { url: string }).url)
}, [message.parts])

const isStreaming = isLastMessage && status === CHAT_STATUS.STREAMING

// Extract file metadata from message text (for user messages with attachments)
Expand Down Expand Up @@ -341,17 +369,17 @@
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
<CopyButton text={getFullTextContent()} />

{selectedModel &&
onRegenerate &&
status !== CHAT_STATUS.STREAMING && (
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={handleRegenerate}
title="Regenerate from this message"
>
<IconRefresh size={16} />
</button>
)}
{onEdit && status !== CHAT_STATUS.STREAMING && (
<EditMessageDialog
message={getFullTextContent()}
imageUrls={imageUrls.length > 0 ? imageUrls : undefined}
onSave={handleEdit}
/>
)}

{onDelete && status !== CHAT_STATUS.STREAMING && (
<DeleteMessageDialog onDelete={handleDelete} />
)}
</div>
)}

Expand All @@ -367,11 +395,22 @@
>
<CopyButton text={getFullTextContent()} />

{selectedModel && onRegenerate && !isStreaming && (
{onEdit && !isStreaming && (
<EditMessageDialog
message={getFullTextContent()}
onSave={handleEdit}
/>
)}

Comment on lines +398 to +404
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing users to edit assistant messages (lines 398-403) is problematic because assistant messages represent AI-generated content that the user didn't create. Editing an assistant message and then regenerating from it could lead to confusion about what was AI-generated vs. user-modified. Consider restricting edit functionality to user messages only (role === 'user'), similar to how it's already done at lines 372-378. If editing assistant messages is intentionally allowed, this should be clearly documented with a use case justification.

Suggested change
{onEdit && !isStreaming && (
<EditMessageDialog
message={getFullTextContent()}
onSave={handleEdit}
/>
)}

Copilot uses AI. Check for mistakes.
{onDelete && !isStreaming && (
<DeleteMessageDialog onDelete={handleDelete} />
)}

Comment on lines +405 to +408
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing users to delete assistant messages (lines 405-407) could break conversation flow and context. If an assistant message is deleted but its corresponding user message remains, the conversation becomes incomplete. Consider either: (1) restricting delete to user messages only, or (2) implementing cascade deletion where deleting a user message also deletes all subsequent messages until the next user message, and deleting an assistant message deletes all messages from the preceding user message onwards. This would maintain conversation integrity.

Suggested change
{onDelete && !isStreaming && (
<DeleteMessageDialog onDelete={handleDelete} />
)}

Copilot uses AI. Check for mistakes.
{selectedModel && onRegenerate && !isStreaming && isLastMessage && (
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={handleRegenerate}
title="Regenerate from this message"
title="Regenerate response"
>
<IconRefresh size={16} />
</button>
Expand Down
38 changes: 13 additions & 25 deletions web-app/src/containers/dialogs/DeleteMessageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'

interface DeleteMessageDialogProps {
onDelete: () => void
Expand All @@ -39,26 +34,19 @@ export function DeleteMessageDialog({ onDelete }: DeleteMessageDialogProps) {
}

const trigger = (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsOpen(true)
}
}}
>
<IconTrash size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('delete')}</p>
</TooltipContent>
</Tooltip>
<div
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsOpen(true)
}
}}
>
<IconTrash size={16} />
</div>
Comment on lines +37 to +49
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the Tooltip components reduces accessibility for users who rely on tooltips to understand button functionality. Consider adding an aria-label attribute to the trigger div (e.g., aria-label="Delete message") to maintain accessibility for screen reader users, especially since this is an icon-only button without a visible text label.

Copilot uses AI. Check for mistakes.
)

return (
Expand Down
49 changes: 16 additions & 33 deletions web-app/src/containers/dialogs/EditMessageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,11 @@ import {
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { IconPencil, IconX } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'

interface EditMessageDialogProps {
message: string
imageUrls?: string[]
onSave: (message: string, imageUrls?: string[]) => void
onSave: (message: string) => void
triggerElement?: React.ReactNode
}

Expand Down Expand Up @@ -53,14 +48,9 @@ export function EditMessageDialog({

const handleSave = () => {
const hasTextChanged = draft !== message && draft.trim()
const hasImageChanged =
JSON.stringify(imageUrls || []) !== JSON.stringify(keptImages)

if (hasTextChanged || hasImageChanged) {
onSave(
draft.trim() || message,
keptImages.length > 0 ? keptImages : undefined
)
if (hasTextChanged) {
onSave(draft.trim() || message)
setIsOpen(false)
}
}
Expand All @@ -73,26 +63,19 @@ export function EditMessageDialog({
}

const defaultTrigger = (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsOpen(true)
}
}}
>
<IconPencil size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('edit')}</p>
</TooltipContent>
</Tooltip>
<div
className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsOpen(true)
}
}}
>
<IconPencil size={16} />
</div>
Comment on lines +66 to +78
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the Tooltip components reduces accessibility for users who rely on tooltips to understand button functionality. The CopyButton at line 16-28 in CopyButton.tsx shows text when copied but the edit/delete buttons now have no hover tooltip or accessible label. Consider adding aria-label attributes to the trigger divs (e.g., aria-label="Edit message") to maintain accessibility for screen reader users, especially since these are icon-only buttons without visible text labels.

Copilot uses AI. Check for mistakes.
)

return (
Expand Down
82 changes: 80 additions & 2 deletions web-app/src/routes/threads/$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
extractContentPartsFromUIMessage,
} from '@/lib/messages'
import { newUserThreadContent } from '@/lib/completion'
import { ThreadMessage, MessageStatus, ChatCompletionRole } from '@janhq/core'
import { ThreadMessage, MessageStatus, ChatCompletionRole, ContentType } from '@janhq/core'
import { createImageAttachment } from '@/types/attachment'
import {
useChatAttachments,
Expand Down Expand Up @@ -566,7 +566,7 @@
// Handle regenerate from any message (user or assistant)
// - For user messages: keeps the user message, deletes all after, regenerates assistant response
// - For assistant messages: finds the closest preceding user message, deletes from there
const handleRegenerate = (messageId?: string) => {

Check warning on line 569 in web-app/src/routes/threads/$threadId.tsx

View workflow job for this annotation

GitHub Actions / test-on-macos

The 'handleRegenerate' function makes the dependencies of useCallback Hook (at line 738) change on every render. To fix this, wrap the definition of 'handleRegenerate' in its own useCallback() Hook

Check warning on line 569 in web-app/src/routes/threads/$threadId.tsx

View workflow job for this annotation

GitHub Actions / test-on-windows-pr

The 'handleRegenerate' function makes the dependencies of useCallback Hook (at line 738) change on every render. To fix this, wrap the definition of 'handleRegenerate' in its own useCallback() Hook

Check warning on line 569 in web-app/src/routes/threads/$threadId.tsx

View workflow job for this annotation

GitHub Actions / test-on-ubuntu

The 'handleRegenerate' function makes the dependencies of useCallback Hook (at line 738) change on every render. To fix this, wrap the definition of 'handleRegenerate' in its own useCallback() Hook

Check warning on line 569 in web-app/src/routes/threads/$threadId.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

The 'handleRegenerate' function makes the dependencies of useCallback Hook (at line 738) change on every render. To fix this, wrap the definition of 'handleRegenerate' in its own useCallback() Hook
if (!languageModelId || !languageModelProvider) {
console.warn('No language model available')
return
Expand Down Expand Up @@ -613,6 +613,82 @@
regenerate(messageId ? { messageId } : undefined)
}

// Handle edit message - updates the message and regenerates from it
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The imageUrls parameter is prefixed with an underscore indicating it's intentionally unused, but the EditMessageDialog is designed to handle image editing (lines 25-26 in EditMessageDialog.tsx show imageUrls is used). This means edited images won't be persisted to the backend when a message is edited. Consider removing the underscore and implementing proper image handling in the updateMessage call, or document why images aren't being persisted during edits.

Suggested change
// Handle edit message - updates the message and regenerates from it
// Handle edit message - updates the message and regenerates from it.
//
// Note: EditMessageDialog can provide `imageUrls` when a message is edited,
// but at the moment we only persist text changes here. Image additions,
// removals, or modifications are intentionally not persisted yet. The
// `_imageUrls` parameter is accepted to satisfy the dialog's interface and
// reserved for future support of editing message images.

Copilot uses AI. Check for mistakes.
const handleEditMessage = useCallback(
(messageId: string, newText: string) => {
if (!languageModelId || !languageModelProvider) {
console.warn('No language model available')
return
}

const currentLocalMessages = useMessages.getState().getMessages(threadId)
const messageIndex = currentLocalMessages.findIndex(
(m) => m.id === messageId
)

if (messageIndex === -1) return

const originalMessage = currentLocalMessages[messageIndex]

// Update the message content
const updatedMessage = {
...originalMessage,
content: [
{
type: ContentType.Text,
text: { value: newText, annotations: [] },
},
],
}
updateMessage(updatedMessage)

// Update chat messages for UI
const updatedChatMessages = chatMessages.map((msg) => {
if (msg.id === messageId) {
return {
...msg,
parts: [{ type: 'text' as const, text: newText }],
}
}
return msg
})
setChatMessages(updatedChatMessages)
Comment on lines +645 to +655
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleEditMessage function has potential state synchronization issues. It updates both the backend messages (updateMessage) and the UI chatMessages (setChatMessages) separately, but these updates are not atomic. If a message deletion or regeneration happens concurrently, the states could become inconsistent. Additionally, the function reads chatMessages at line 646 but that value might be stale if the component hasn't re-rendered yet with the latest backend changes. Consider using a single source of truth or ensuring proper state synchronization between the backend messages and UI chatMessages.

Suggested change
// Update chat messages for UI
const updatedChatMessages = chatMessages.map((msg) => {
if (msg.id === messageId) {
return {
...msg,
parts: [{ type: 'text' as const, text: newText }],
}
}
return msg
})
setChatMessages(updatedChatMessages)
// Update chat messages for UI using functional update to avoid stale state
setChatMessages((prevChatMessages) =>
prevChatMessages.map((msg) => {
if (msg.id === messageId) {
return {
...msg,
parts: [{ type: 'text' as const, text: newText }],
}
}
return msg
})
)

Copilot uses AI. Check for mistakes.

// Delete all messages after this one and regenerate
const messagesToDelete = currentLocalMessages.slice(messageIndex + 1)
messagesToDelete.forEach((msg) => {
deleteMessage(threadId, msg.id)
})

Check warning on line 662 in web-app/src/routes/threads/$threadId.tsx

View workflow job for this annotation

GitHub Actions / base_branch_cov

React Hook useCallback has a missing dependency: 'handleRegenerate'. Either include it or remove the dependency array
// Regenerate from the edited message
regenerate({ messageId })
},
[
languageModelId,
languageModelProvider,
threadId,
updateMessage,
deleteMessage,
chatMessages,
setChatMessages,
regenerate,
]
)

// Handle delete message
const handleDeleteMessage = useCallback(
(messageId: string) => {
deleteMessage(threadId, messageId)

// Update chat messages for UI
const updatedChatMessages = chatMessages.filter(
(msg) => msg.id !== messageId
)
setChatMessages(updatedChatMessages)
},
[threadId, deleteMessage, chatMessages, setChatMessages]
)
Comment on lines +679 to +690
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleDeleteMessage function only updates the local state (backend via deleteMessage and UI via setChatMessages) but doesn't consider the implications for conversation continuity. If an assistant message is deleted, the conversation might become incoherent (a user message with no response), or if a user message is deleted, the following assistant message loses its context. Consider adding validation to prevent deletion of messages that would break conversation flow, or implement cascade deletion logic (e.g., when deleting a user message, also delete subsequent assistant responses until the next user message).

Copilot uses AI. Check for mistakes.

// Handler for increasing context size
const handleContextSizeIncrease = useCallback(async () => {
if (!selectedModel) return
Expand Down Expand Up @@ -659,7 +735,7 @@
setTimeout(() => {
handleRegenerate()
}, 1000)
}, [selectedModel, selectedProvider, getProviderByName, serviceHub])
}, [selectedModel, selectedProvider, getProviderByName, serviceHub, handleRegenerate])

const threadModel = useMemo(() => thread?.model, [thread])

Expand Down Expand Up @@ -698,6 +774,8 @@
status={status}
reasoningContainerRef={reasoningContainerRef}
onRegenerate={handleRegenerate}
onEdit={handleEditMessage}
onDelete={handleDeleteMessage}
/>
)
})}
Expand Down
Loading