返回 JoyAI-Echo
ChatList.tsx
1 import { MoreHorizontal, Trash2 } from "lucide-react";
2 import { useTranslation } from "react-i18next";
3
4 import {
5 DropdownMenu,
6 DropdownMenuContent,
7 DropdownMenuItem,
8 DropdownMenuTrigger,
9 } from "@/components/ui/dropdown-menu";
10 import { ScrollArea } from "@/components/ui/scroll-area";
11 import { deriveTitle, relativeTime } from "@/lib/format";
12 import { cn } from "@/lib/utils";
13 import type { ChatSummary } from "@/lib/types";
14
15 interface ChatListProps {
16 sessions: ChatSummary[];
17 activeKey: string | null;
18 onSelect: (key: string) => void;
19 onRequestDelete: (key: string, label: string) => void;
20 loading?: boolean;
21 }
22
23 function titleFor(s: ChatSummary, fallbackTitle: string): string {
24 const title = deriveTitle(s.preview, fallbackTitle);
25 return title.length > 48 ? `${title.slice(0, 45)}…` : title;
26 }
27
28 function sessionModeLabelKey(s: ChatSummary): string {
29 if (s.autoGenerate === true) return "chat.mode.autoGenerate";
30 return "chat.mode.stepwise";
31 }
32
33 export function ChatList({
34 sessions,
35 activeKey,
36 onSelect,
37 onRequestDelete,
38 loading,
39 }: ChatListProps) {
40 const { t } = useTranslation();
41 if (loading && sessions.length === 0) {
42 return (
43 <div className="px-3 py-6 text-[12px] text-muted-foreground">
44 {t("chat.loading")}
45 </div>
46 );
47 }
48
49 if (sessions.length === 0) {
50 return (
51 <div className="px-3 py-6 text-xs text-muted-foreground">
52 {t("chat.noSessions")}
53 </div>
54 );
55 }
56
57 return (
58 <ScrollArea className="h-full">
59 <ul className="space-y-0.5 px-2 py-1">
60 {sessions.map((s) => {
61 const active = s.key === activeKey;
62 const title = titleFor(
63 s,
64 t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
65 );
66 return (
67 <li key={s.key}>
68 <div
69 className={cn(
70 "group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors",
71 active
72 ? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]"
73 : "text-sidebar-foreground/88 hover:bg-sidebar-accent/45",
74 )}
75 >
76 <button
77 type="button"
78 onClick={() => onSelect(s.key)}
79 className="flex min-w-0 flex-1 flex-col items-start text-left"
80 >
81 <span className="w-full truncate font-medium leading-5 break-all whitespace-break-spaces">
82 {title}
83 </span>
84 <span className="flex items-center gap-1.5 text-[10.5px] text-muted-foreground/80">
85 <span className="rounded bg-foreground/8 px-1 py-px text-[9.5px] font-medium text-muted-foreground">
86 {t(sessionModeLabelKey(s))}
87 </span>
88 {relativeTime(s.updatedAt ?? s.createdAt) || "—"}
89 </span>
90 </button>
91 <DropdownMenu modal={false}>
92 <DropdownMenuTrigger
93 className={cn(
94 "inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity",
95 "hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
96 "focus-visible:opacity-100",
97 active && "opacity-100",
98 )}
99 aria-label={t("chat.actions", { title })}
100 >
101 <MoreHorizontal className="h-4 w-4" />
102 </DropdownMenuTrigger>
103 <DropdownMenuContent
104 align="end"
105 onCloseAutoFocus={(event) => event.preventDefault()}
106 >
107 <DropdownMenuItem
108 onSelect={() => {
109 window.setTimeout(
110 () => onRequestDelete(s.key, title),
111 0,
112 );
113 }}
114 className="text-destructive focus:text-destructive"
115 >
116 <Trash2 className="mr-2 h-4 w-4" />
117 {t("chat.delete")}
118 </DropdownMenuItem>
119 </DropdownMenuContent>
120 </DropdownMenu>
121 </div>
122 </li>
123 );
124 })}
125 </ul>
126 </ScrollArea>
127 );
128 }
129
129 lines Plain Text