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.
391 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|