gitdataai/src/app/chat/ChatMessageList.tsx
ZhenYi cab064f83f refactor(ui): update chat page components for new theme system
Update all Chat*.tsx components to use CSS variable-based theme
tokens, improve layout and styling consistency across conversation
list, header, message bubbles, input, message list, model selector,
and slash command menu.
2026-05-18 20:43:58 +08:00

623 lines
19 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, useCallback } from "react"
import {
Loader2,
Code,
FileText,
GitPullRequest,
Brain,
ChevronDown,
Sparkles,
} from "lucide-react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { useQueryClient } from "@tanstack/react-query"
import { useMessagesQuery } from "@/hooks/useAiChatQuery"
import { useStreamingStore } from "@/store/streaming"
import type { StreamPart } from "@/store/streaming"
import { ChatMessageBubble } from "./ChatMessageBubble"
import { useChatPage } from "./ChatPageContext"
import { getModelIcon } from "@/lib/icons/modelIcons"
import { IrRenderer } from "@/lib/ir/renderer"
import { Shimmer } from "@/components/ai-elements/shimmer"
import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from "@/components/ai-elements/reasoning"
import { ToolCallBlock } from "@/components/chat/ToolCallBlock"
import { useCodePreview } from "@/components/chat/CodePreviewContext"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { t } from "@/i18n/T"
interface ChatMessageListProps {
conversationId: string | null
setIsStreaming: (value: boolean) => void
}
const OVERSCAN = 3
const ESTIMATED_SIZE = 200
const PROSE_CLASS =
"prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1"
const PROMPT_SUGGESTIONS = [
{ icon: Code, text: t("chat.message_list.explain_code") },
{ icon: FileText, text: t("chat.message_list.summarize_doc") },
{ icon: GitPullRequest, text: t("chat.message_list.review_pr") },
{ icon: Brain, text: t("chat.message_list.brainstorm") },
]
export function ChatMessageList({
conversationId,
setIsStreaming,
}: ChatMessageListProps) {
const { data, isLoading } = useMessagesQuery(conversationId || "")
const scrollRef = useRef<HTMLDivElement>(null)
const messages = useMemo(() => data?.messages ?? [], [data?.messages])
const stream = useStreamingStore((s) =>
conversationId ? s.streams[conversationId] : undefined
)
const isStreaming = stream && !stream.isDone
const queryClient = useQueryClient()
const codePreview = useCodePreview()
const [isAtBottom, setIsAtBottom] = useState(true)
const [userScrolledUp, setUserScrolledUp] = useState(false)
const [activeUserAnchor, setActiveUserAnchor] = useState(0)
const checkAtBottom = useCallback(() => {
const el = scrollRef.current
if (!el) return
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
const atBottom = distance < 200
setIsAtBottom(atBottom)
if (atBottom) setUserScrolledUp(false)
}, [])
const userAnchors = useMemo(() => {
return messages
.map((message, index) => ({ message, index }))
.filter(({ message }) => message.role === "user")
.map(({ index }) => index)
}, [messages])
const showUserTimeline = userAnchors.length > 0 && !codePreview?.activeCode
const hasStreamingBubble = !!stream?.parts && stream.parts.length > 0
const streamContentLength =
stream?.parts.reduce((sum, part) => sum + part.content.length, 0) ?? 0
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ESTIMATED_SIZE,
overscan: OVERSCAN,
})
const updateActiveUserAnchor = useCallback(() => {
if (messages.length === 0 || userAnchors.length === 0) {
setActiveUserAnchor(0)
return
}
const visibleItems = virtualizer.getVirtualItems()
if (visibleItems.length === 0) return
const firstVisible = visibleItems[0]?.index ?? 0
let closestAnchor = 0
for (let i = 0; i < userAnchors.length; i++) {
if (userAnchors[i] <= firstVisible) {
closestAnchor = i
} else {
break
}
}
setActiveUserAnchor(closestAnchor)
}, [messages.length, userAnchors, virtualizer])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
const atBottom = distance < 200
setIsAtBottom(atBottom)
if (!atBottom) setUserScrolledUp(true)
updateActiveUserAnchor()
}, [updateActiveUserAnchor])
useEffect(() => {
updateActiveUserAnchor()
}, [updateActiveUserAnchor, streamContentLength])
useEffect(() => {
if (isAtBottom && scrollRef.current) {
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
})
}
}, [messages.length, streamContentLength, isAtBottom])
useEffect(() => {
if (conversationId && messages.length > 0) {
checkAtBottom()
}
}, [conversationId, messages.length, checkAtBottom])
useEffect(() => {
if (scrollRef.current && messages.length > 0) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [isLoading, messages.length])
if (!conversationId || (messages.length === 0 && !hasStreamingBubble)) {
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-8"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<div className="mx-auto flex max-w-3xl flex-col items-center gap-6">
<Empty className="border border-[var(--border-subtle)] bg-[var(--surface-secondary)]/70 py-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<Sparkles />
</EmptyMedia>
<EmptyTitle>{t("chat.message_list.welcome_title")}</EmptyTitle>
<EmptyDescription>
{t("chat.message_list.welcome_desc")}
</EmptyDescription>
</EmptyHeader>
</Empty>
<div className="grid w-full gap-3 sm:grid-cols-2">
{PROMPT_SUGGESTIONS.map((s, i) => (
<button
key={i}
className="flex items-start gap-2.5 rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-4 text-left transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-default)] hover:bg-[var(--surface-elevated)] hover:shadow-sm"
style={{
color: "var(--text-secondary)",
}}
>
<s.icon
className="mt-0.5 size-4 shrink-0"
style={{ color: "var(--text-muted)" }}
/>
<span className="text-[13px] leading-relaxed">{s.text}</span>
</button>
))}
</div>
</div>
</div>
)
}
if (isLoading) {
return (
<div
className="flex flex-1 items-center justify-center"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<Loader2
className="size-5 animate-spin"
style={{ color: "var(--accent)" }}
/>
</div>
)
}
return (
<div
className="relative flex min-h-0 flex-1 flex-col"
style={{ backgroundColor: "var(--surface-ground)" }}
>
{isStreaming && userScrolledUp && (
<div
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 cursor-pointer rounded-full border px-4 py-1.5 shadow-lg transition-all hover:scale-105"
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-subtle)",
}}
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
})
setUserScrolledUp(false)
setIsAtBottom(true)
}
}}
>
<div className="flex items-center gap-2">
<Shimmer duration={1}>
{t("chat.message_list.new_response")}
</Shimmer>
<ChevronDown
className="size-3.5"
style={{ color: "var(--text-muted)" }}
/>
</div>
</div>
)}
<div
ref={scrollRef}
className="app-scrollbar flex-1 overflow-y-auto pb-24"
onScroll={handleScroll}
style={{ backgroundColor: "var(--surface-ground)" }}
>
{showUserTimeline && (
<div className="pointer-events-none absolute top-6 bottom-28 left-4 z-10 hidden w-4 md:block">
<div className="relative h-full w-full">
<div
className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2"
style={{ backgroundColor: "var(--border-default)" }}
/>
<div className="relative flex h-full flex-col items-center justify-between py-1">
{userAnchors.map((messageIndex, anchorIndex) => {
const isActive = anchorIndex === activeUserAnchor
return (
<button
key={`${messageIndex}-${anchorIndex}`}
onClick={() => {
virtualizer.scrollToIndex(messageIndex, {
align: "center",
behavior: "smooth",
})
}}
className="pointer-events-auto flex size-4 items-center justify-center rounded-full transition-all"
title={t("chat.message_list.jump_to_message", {
index: anchorIndex + 1,
})}
aria-label={t("chat.message_list.jump_to_message", {
index: anchorIndex + 1,
})}
>
<span
className="block rounded-full transition-all"
style={{
width: isActive ? 7 : 5,
height: isActive ? 7 : 5,
backgroundColor: isActive
? "var(--accent)"
: "var(--border-strong)",
boxShadow: "0 0 0 2px var(--surface-ground)",
opacity: isActive ? 1 : 0.72,
}}
/>
</button>
)
})}
</div>
</div>
</div>
)}
<div
className="relative mx-auto max-w-3xl"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index]
if (!message) return null
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ChatMessageBubble
message={message}
conversationId={conversationId}
onRegenerate={() => {
queryClient.invalidateQueries({
queryKey: ["ai-messages", conversationId],
})
queryClient.invalidateQueries({
queryKey: ["ai-conversations", conversationId],
})
}}
setIsStreaming={setIsStreaming}
/>
</div>
)
})}
</div>
{hasStreamingBubble && (
<div className="mx-auto w-full max-w-3xl">
<StreamingBubble
parts={stream!.parts}
isDone={stream!.isDone}
conversationId={conversationId}
/>
</div>
)}
</div>
</div>
)
}
function StreamingBubble({
parts,
isDone,
conversationId,
}: {
parts: StreamPart[]
isDone: boolean
conversationId: string
}) {
const { selectedModel } = useChatPage()
const [displayParts, setDisplayParts] = useState<StreamPart[]>([])
const [displayDone, setDisplayDone] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
const latestRef = useRef({ parts, isDone })
const rafRef = useRef<number>(0)
useEffect(() => {
latestRef.current = { parts, isDone }
})
const hasParts = parts.length > 0
useEffect(() => {
if (!hasParts) return
const sync = () => {
const { parts: p, isDone: d } = latestRef.current
setDisplayParts([...p])
setDisplayDone(d)
if (!d) {
rafRef.current = requestAnimationFrame(sync)
}
}
rafRef.current = requestAnimationFrame(sync)
return () => cancelAnimationFrame(rafRef.current)
}, [hasParts])
useEffect(() => {
if (contentRef.current) {
contentRef.current.style.height = "auto"
requestAnimationFrame(() => {
if (contentRef.current) {
contentRef.current.style.height = ""
}
})
}
}, [displayParts.length])
const activeThinkingIdx =
!displayDone && displayParts[displayParts.length - 1]?.type === "thinking"
? displayParts.length - 1
: -1
return (
<div
ref={contentRef}
className="mx-auto flex w-full max-w-3xl gap-4 rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-secondary)]/70 px-4 py-3 shadow-sm backdrop-blur"
>
<div className="shrink-0 pt-0.5">
<StreamingModelAvatar modelName={selectedModel?.model_name} size={28} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span
className="text-xs font-semibold"
style={{ color: "var(--text-primary)" }}
>
{selectedModel?.model_name || "Assistant"}
</span>
{!displayDone && (
<span
className="animate-pulse text-[11px]"
style={{ color: "var(--text-muted)" }}
>
{t("chat.message_list.responding")}
</span>
)}
</div>
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
{displayParts.map((part, i) => {
if (part.type === "thinking") {
return (
<StreamingReasoningBlock
key={i}
content={part.content}
isActivelyThinking={i === activeThinkingIdx}
/>
)
}
if (part.type === "tool_call") {
if (part.toolName !== "call_sub_agent") return null
return (
<ToolCallBlock
key={i}
toolName={part.toolName || "unknown"}
args={part.toolArgs || {}}
status={displayDone ? "ok" : "pending"}
childrenId={part.children_id}
subAgentOutput={part.subAgentOutput}
conversationId={conversationId}
/>
)
}
if (part.type === "tool_result") {
if (part.toolName !== "call_sub_agent") return null
return (
<ToolCallBlock
key={i}
toolName={part.toolName || "unknown"}
args={part.toolArgs || {}}
status={part.toolStatus || "ok"}
result={part.content}
childrenId={part.children_id}
subAgentOutput={part.subAgentOutput}
conversationId={conversationId}
/>
)
}
const isLast = i === displayParts.length - 1
return (
<div key={i}>
<IrRenderer nodes={part.irNodes} className={PROSE_CLASS} />
{isLast && !displayDone && <StreamingCursor />}
</div>
)
})}
</div>
</div>
</div>
)
}
function StreamingReasoningBlock({
content,
isActivelyThinking,
}: {
content: string
isActivelyThinking: boolean
}) {
const [manualOpen, setManualOpen] = useState(false)
const [autoCollapsed, setAutoCollapsed] = useState(false)
const wasActivelyThinkingRef = useRef(isActivelyThinking)
useEffect(() => {
const wasActivelyThinking = wasActivelyThinkingRef.current
if (isActivelyThinking && !wasActivelyThinking) {
setAutoCollapsed(false)
setManualOpen(false)
}
if (!isActivelyThinking && wasActivelyThinking) {
setAutoCollapsed(false)
setManualOpen(false)
}
wasActivelyThinkingRef.current = isActivelyThinking
}, [isActivelyThinking])
const isOpen = isActivelyThinking ? !autoCollapsed : manualOpen
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (isActivelyThinking) {
setAutoCollapsed(!nextOpen)
return
}
setManualOpen(nextOpen)
},
[isActivelyThinking]
)
return (
<Reasoning
autoLifecycle={false}
isStreaming={isActivelyThinking}
onOpenChange={handleOpenChange}
open={isOpen}
>
<ReasoningTrigger />
<ReasoningContent>{content}</ReasoningContent>
</Reasoning>
)
}
function StreamingCursor() {
return (
<span
className="ml-[1px] inline-block h-[1.1em] w-[2px] animate-pulse rounded-sm align-text-bottom"
style={{
backgroundColor: "var(--accent)",
animationDuration: "0.8s",
}}
/>
)
}
const AVATAR_COLORS = [
"#6366f1",
"#8b5cf6",
"#d946ef",
"#ec4899",
"#f43f5e",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
"#2563eb",
"#7c3aed",
"#c026d3",
]
function hashColor(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
}
function StreamingModelAvatar({
modelName,
size = 28,
}: {
modelName?: string | null
size?: number
}) {
if (!modelName) {
return (
<div
className="flex shrink-0 items-center justify-center rounded-full"
style={{
width: size,
height: size,
backgroundColor: "var(--accent-muted)",
}}
>
<Sparkles
className="h-3.5 w-3.5"
style={{
color: "var(--accent)",
width: size * 0.5,
height: size * 0.5,
}}
/>
</div>
)
}
const Icon = getModelIcon(modelName)
if (Icon) {
return (
<div className="shrink-0" style={{ width: size, height: size }}>
<Icon.Avatar size={size} />
</div>
)
}
return (
<div
className="flex shrink-0 items-center justify-center rounded-full font-bold"
style={{
width: size,
height: size,
backgroundColor: hashColor(modelName),
fontSize: Math.max(10, size * 0.35),
color: "var(--text-inverse)",
}}
>
{modelName[0]?.toUpperCase() || "?"}
</div>
)
}