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.
623 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|