gitdataai/src/app/chat/ChatConversationList.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

391 lines
12 KiB
TypeScript

import { useState, useMemo, useEffect, useRef } from "react"
import { Link } from "react-router-dom"
import {
Plus,
Trash2,
MessageSquare,
Loader2,
Search,
Edit2,
X,
Sparkles,
} from "lucide-react"
import {
useConversationsQuery,
useCreateConversationMutation,
useDeleteConversationMutation,
} from "@/hooks/useAiChatQuery"
import { useChatPage } from "./ChatPageContext"
import type { ConversationResponse } from "@/client/model"
import { t } from "@/i18n/T"
import { Badge } from "@/components/ui/badge"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
interface ChatConversationListProps {
selectedId: string | null
onSelect: (id: string) => void
onNew: () => void
}
export function ChatConversationList({
selectedId,
onSelect,
onNew,
}: ChatConversationListProps) {
const { scope, projectId, projectName, selectedModel } = useChatPage()
const { data, isLoading } = useConversationsQuery(
scope === "project" ? projectId : null
)
const createMutation = useCreateConversationMutation()
const deleteMutation = useDeleteConversationMutation()
const [deletingId, setDeletingId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [isSearchOpen, setIsSearchOpen] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const getConversationLink = (id: string) => {
if (scope === "project" && projectName) return `/${projectName}/chat/${id}`
return `/me/chat/${id}`
}
const conversations = useMemo(
() => data?.conversations || [],
[data?.conversations]
)
// Filter conversations by search query
const filteredConversations = useMemo(() => {
if (!searchQuery.trim()) return conversations
const q = searchQuery.toLowerCase()
return conversations.filter((c) =>
(c.title || "Untitled Chat").toLowerCase().includes(q)
)
}, [conversations, searchQuery])
// Group conversations by date
const groupedConversations = useMemo(() => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const weekAgo = new Date(today)
weekAgo.setDate(weekAgo.getDate() - 7)
const monthAgo = new Date(today)
monthAgo.setMonth(monthAgo.getMonth() - 1)
const groups: { label: string; items: ConversationResponse[] }[] = []
const todayItems: ConversationResponse[] = []
const yesterdayItems: ConversationResponse[] = []
const thisWeekItems: ConversationResponse[] = []
const thisMonthItems: ConversationResponse[] = []
const earlierItems: ConversationResponse[] = []
for (const c of filteredConversations) {
const date = new Date(c.created_at)
if (date >= today) {
todayItems.push(c)
} else if (date >= yesterday) {
yesterdayItems.push(c)
} else if (date >= weekAgo) {
thisWeekItems.push(c)
} else if (date >= monthAgo) {
thisMonthItems.push(c)
} else {
earlierItems.push(c)
}
}
if (todayItems.length > 0)
groups.push({ label: "Today", items: todayItems })
if (yesterdayItems.length > 0)
groups.push({ label: "Yesterday", items: yesterdayItems })
if (thisWeekItems.length > 0)
groups.push({ label: "This Week", items: thisWeekItems })
if (thisMonthItems.length > 0)
groups.push({ label: "This Month", items: thisMonthItems })
if (earlierItems.length > 0)
groups.push({ label: "Earlier", items: earlierItems })
return groups
}, [filteredConversations])
const handleNew = async () => {
onNew()
try {
const conversation = await createMutation.mutateAsync({
project_id: scope === "project" ? projectId : null,
title: "New Chat",
model: selectedModel?.model_name ?? null,
model_config: null,
})
if (conversation?.id) {
onSelect(conversation.id)
}
} catch (err) {
console.error("Failed to create conversation:", err)
}
}
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
e.preventDefault()
setDeletingId(id)
try {
await deleteMutation.mutateAsync(id)
if (selectedId === id) {
onNew()
}
} catch (err) {
console.error("Failed to delete conversation:", err)
} finally {
setDeletingId(null)
}
}
// Cmd/Ctrl+K to focus search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setIsSearchOpen(true)
setTimeout(() => searchInputRef.current?.focus(), 0)
}
if (e.key === "Escape" && isSearchOpen) {
setIsSearchOpen(false)
setSearchQuery("")
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isSearchOpen])
return (
<div
className="flex h-full shrink-0 flex-col border-r border-[var(--border-subtle)] bg-[var(--surface-sidebar)]/95 backdrop-blur-xl"
style={{ width: 260 }}
>
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-[var(--border-subtle)] px-4 py-4">
<div>
<span
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
{t("chat.conversations.chat_history")}
</span>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
{conversations.length} conversations
</p>
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => setIsSearchOpen(!isSearchOpen)}
className="flex size-8 items-center justify-center rounded-full transition-colors hover:bg-[var(--hover-bg)]"
style={{ color: "var(--text-muted)" }}
title={t("navigation.search_shortcut")}
>
<Search className="size-3.5" />
</button>
<button
onClick={handleNew}
disabled={createMutation.isPending}
className="flex size-8 items-center justify-center rounded-full transition-colors hover:bg-[var(--hover-bg)]"
style={{ color: "var(--text-secondary)" }}
title={t("chat.header.new_chat")}
>
{createMutation.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Edit2 className="size-3.5" />
)}
</button>
</div>
</div>
{/* Search Input */}
{isSearchOpen && (
<div className="shrink-0 px-3 py-2">
<div
className="flex items-center gap-2 rounded-xl border px-3 py-2"
style={{
backgroundColor: "var(--surface-ground)",
borderColor: "var(--border-default)",
}}
>
<Search
className="size-3.5 shrink-0"
style={{ color: "var(--text-muted)" }}
/>
<input
ref={searchInputRef}
type="text"
placeholder={t("chat.conversations.search_placeholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
style={{ color: "var(--text-primary)" }}
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="shrink-0"
style={{ color: "var(--text-muted)" }}
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
)}
{/* New Chat Button */}
<div className="px-3 py-2">
<button
onClick={handleNew}
disabled={createMutation.isPending}
className="flex w-full items-center justify-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-all duration-200"
style={{
borderColor: "var(--border-subtle)",
color: "var(--text-primary)",
backgroundColor: "var(--surface-ground)",
}}
>
<Plus className="size-4" />
{t("chat.conversations.new_chat")}
</button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto px-2 pb-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2
className="size-5 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div>
) : filteredConversations.length === 0 ? (
<Empty className="mt-6 border-[var(--border-subtle)] bg-[var(--surface-secondary)]/60 py-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageSquare />
</EmptyMedia>
<EmptyTitle>
{searchQuery
? t("chat.conversations.no_matching")
: t("chat.conversations.no_conversations")}
</EmptyTitle>
<EmptyDescription>
{searchQuery
? t("chat.conversations.try_different_search")
: t("chat.conversations.start_new_chat")}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="space-y-3">
{groupedConversations.map((group) => (
<div key={group.label}>
<div
className="px-3 py-1 text-[11px] font-semibold tracking-[0.2em] uppercase"
style={{ color: "var(--text-tertiary)" }}
>
{group.label}
</div>
<div className="space-y-0.5">
{group.items.map((conversation) => (
<ConversationItem
key={conversation.id}
conversation={conversation}
isSelected={selectedId === conversation.id}
isDeleting={deletingId === conversation.id}
linkTo={getConversationLink(conversation.id)}
onDelete={(e) => handleDelete(e, conversation.id)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function ConversationItem({
conversation,
isSelected,
isDeleting,
linkTo,
onDelete,
}: {
conversation: ConversationResponse
isSelected: boolean
isDeleting: boolean
linkTo: string
onDelete: (e: React.MouseEvent) => void
}) {
const date = new Date(conversation.created_at)
const now = new Date()
const isToday =
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear()
const timeLabel = isToday
? date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: date.toLocaleDateString([], { month: "short", day: "numeric" })
return (
<Link
to={linkTo}
className="group relative flex cursor-pointer items-center gap-2.5 rounded-xl px-3 py-2 no-underline transition-all duration-150"
style={{
backgroundColor: isSelected ? "var(--hover-bg-strong)" : "transparent",
color: isSelected ? "var(--text-primary)" : "var(--text-secondary)",
border: isSelected
? "1px solid var(--border-default)"
: "1px solid transparent",
}}
>
<MessageSquare
className="size-[15px] shrink-0"
style={{ color: isSelected ? "var(--accent)" : "var(--text-muted)" }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] leading-snug">
{conversation.title || t("chat.conversations.untitled_chat")}
</p>
</div>
<div className="flex items-center gap-1">
<span
className="shrink-0 text-[11px]"
style={{ color: "var(--text-muted)" }}
>
{timeLabel}
</span>
<button
onClick={onDelete}
disabled={isDeleting}
className="shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
style={{ color: "var(--text-muted)" }}
>
{isDeleting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</button>
</div>
</Link>
)
}