import type { UIMessage } from "ai"
import {
AlertCircle,
BookmarkPlus,
Check,
ChevronDown,
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import type { ToolPartLike } from "@/components/chat/types"
import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { useCopy } from "@/lib/clipboard"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
import {
getMessageTextContent,
getUserOriginalText,
splitTextIntoFileSections,
type TextSection,
} from "./message-text"
import { ToolActivity } from "./tool-activity"
function ActionButton({
label,
onClick,
active,
children,
}: {
label: string
onClick: () => void
active?: boolean
children: React.ReactNode
}) {
return (
{children}
)
}
/** Copies a message's text, with a short "copied" state */
function CopyButton({ text }: { text: string }) {
const dict = useDictionary()
const { copied, copy } = useCopy(() =>
toast.error(dict.chat.failedToCopyDetail),
)
return (
copy(text)}
>
{copied ? : }
)
}
function AttachedSection({ section }: { section: TextSection }) {
const [open, setOpen] = useState(false)
const dict = useDictionary()
const Icon =
section.fileType === "pdf"
? FileText
: section.fileType === "url"
? Link
: FileCode
const chars =
section.charCount && section.charCount >= 1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
return (
{
e.stopPropagation()
setOpen((v) => !v)
}}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left hover:bg-accent"
>
{section.filename}
{chars} {dict.file.chars}
{open && (
{section.content}
)}
)
}
function MessageText({ text }: { text: string }) {
const sections = splitTextIntoFileSections(text)
return (
{sections.map((section, index) =>
section.type === "text" ? (
*:first-child]:mt-0 [&>*:last-child]:mb-0",
"prose-p:my-1.5 prose-pre:bg-surface-2 prose-pre:text-foreground prose-code:before:content-none prose-code:after:content-none dark:prose-invert",
)}
>
{section.content}
) : (
),
)}
)
}
function UserMessage({
message,
index,
isLast,
}: {
message: UIMessage
index: number
isLast: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState("")
const [saveTemplate, setSaveTemplate] = useState(false)
const textareaRef = useRef(null)
const text = getMessageTextContent(message)
const canEdit = isLast && !engine.isBusy
useEffect(() => {
if (editing) textareaRef.current?.focus()
}, [editing])
const startEdit = () => {
if (!canEdit) return
setEditText(getUserOriginalText(message))
setEditing(true)
}
const submitEdit = async () => {
if (!editText.trim()) return
// Kept open, with its text, when it could not go now
if (await engine.editMessage(index, editText.trim())) setEditing(false)
}
if (editing) {
return (
)
}
const images = (message.parts ?? []).filter((p) => p.type === "file") as {
url: string
}[]
return (
{canEdit && (
)}
setSaveTemplate(true)}
>
{
// Not for a link or an attachment's toggle inside, nor
// when text was selected to copy it
const target = e.target as Element
if (target.closest("a, button")) return
if (window.getSelection()?.toString()) return
startEdit()
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return
if (canEdit && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
startEdit()
}
}}
title={canEdit ? dict.chat.clickToEdit : undefined}
>
{images.length > 0 && (
{images.map((image, i) => (
))}
)}
{text &&
}
{saveTemplate && (
!open && setSaveTemplate(false)}
onSuccess={() => setSaveTemplate(false)}
initialPrompt={getUserOriginalText(message)}
/>
)}
)
}
function AssistantMessage({
message,
index,
isLastMessage,
isLastAssistant,
}: {
message: UIMessage
index: number
isLastMessage: boolean
isLastAssistant: boolean
}) {
const dict = useDictionary()
// The thinking header in the page language
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return {dict.reasoning.thinking}
}
if (duration === undefined) return {dict.reasoning.thoughtBrief}
return (
{duration === 1
? dict.reasoning.thoughtForOne
: dict.reasoning.thoughtFor.replace(
"{duration}",
String(duration),
)}
)
}
const engine = useChatEngine()
const [feedback, setFeedback] = useState<"good" | "bad" | null>(null)
const isRestored = engine.loadedMessageIdsRef.current.has(message.id)
const parts = message.parts ?? []
const isCachedExample = parts.some((p: any) =>
p.toolCallId?.startsWith("cached-"),
)
const text = getMessageTextContent(message)
const submitFeedback = async (value: "good" | "bad") => {
if (feedback === value) {
setFeedback(null)
return
}
setFeedback(value)
try {
await fetch(getApiEndpoint("/api/log-feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messageId: message.id,
feedback: value,
sessionId: engine.langfuseSessionId,
}),
})
} catch (error) {
console.error("Failed to log feedback:", error)
toast.error(dict.errors.failedToRecordFeedback)
setFeedback(null)
}
}
// Text parts with nothing shown between them render together (a step
// boundary of an automatic retry shows nothing); tools render in place
const blocks: {
kind: "text" | "tool" | "reasoning"
index: number
texts: string[]
}[] = []
parts.forEach((part, partIndex) => {
if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
else if (part.type?.startsWith("tool-"))
blocks.push({ kind: "tool", index: partIndex, texts: [] })
else if (part.type === "text") {
const text = (part as { text: string }).text
const last = blocks[blocks.length - 1]
if (last?.kind === "text") last.texts.push(text)
else blocks.push({ kind: "text", index: partIndex, texts: [text] })
}
})
return (
{blocks.map((block) => {
const part = parts[block.index]
if (block.kind === "reasoning") {
const isStreamingReasoning =
engine.status === "streaming" &&
isLastMessage &&
block.index === parts.length - 1
return (
{(part as { text: string }).text}
)
}
if (block.kind === "tool") {
const toolPart = part as unknown as ToolPartLike
const validationState =
engine.validationStates[toolPart.toolCallId]
return (
{toolPart.type === "tool-display_diagram" &&
validationState && (
)}
)
}
const run = block.texts.join("\n")
if (!run.trim()) return null
return
})}
{!(engine.isBusy && isLastMessage) && (
{text &&
}
{isLastAssistant && !isCachedExample && (
engine.regenerate(index)}
>
)}
submitFeedback("good")}
>
submitFeedback("bad")}
>
)}
)
}
/**
* A failed request: what to do in the text color, the provider's own words
* below it, and the action that can fix it first
*/
function ErrorNotice({
text,
openModelConfig,
onOpenModelConfig,
onRetry,
}: {
text: string
openModelConfig: boolean
onOpenModelConfig: () => void
onRetry?: () => void
}) {
const dict = useDictionary()
// Provider errors are "hint\n\nprovider message"
const split = text.indexOf("\n\n")
const headline = split >= 0 ? text.slice(0, split) : text
const detail = split >= 0 ? text.slice(split + 2).trim() : ""
const button =
"inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-xs font-medium"
return (
{headline}
{detail && (
{detail}
)}
{(openModelConfig || onRetry) && (
{openModelConfig && (
{dict.errors.llm.openModelSettings}
)}
{onRetry && (
{dict.chat.retry}
)}
)}
)
}
export function MessageList() {
const dict = useDictionary()
const openSettings = useUiStore((s) => s.openSettings)
const engine = useChatEngine()
const { messages } = engine
const endRef = useRef(null)
const prevCountRef = useRef(0)
const throttleRef = useRef | null>(null)
// Keep the newest message in view; jump instantly on bulk loads
useEffect(() => {
if (!endRef.current || messages.length === 0) return
const prev = prevCountRef.current
prevCountRef.current = messages.length
if (prev === 0 || messages.length - prev > 1) {
endRef.current.scrollIntoView({ behavior: "instant", block: "end" })
return
}
if (!throttleRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" })
throttleRef.current = setTimeout(() => {
throttleRef.current = null
endRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
})
}, 150)
}
}, [messages])
const lastAssistantIndex = messages
.map((m) => m.role)
.lastIndexOf("assistant")
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return (
{messages.map((message, index) => {
const isRestored = engine.loadedMessageIdsRef.current.has(
message.id,
)
return (
{message.role === "user" ? (
) : message.role === "assistant" ? (
) : (
{
const { providerId } =
(message.metadata as
| { providerId?: string }
| undefined) ?? {}
openSettings(
"models",
providerId ? { providerId } : "list",
)
}}
onRetry={
index === messages.length - 1 &&
!engine.isBusy &&
lastUserIndex >= 0
? engine.retryLastMessage
: undefined
}
/>
)}
)
})}
{engine.status === "submitted" &&
messages[messages.length - 1]?.role === "user" && (
{dict.reasoning.thinking}
)}
)
}