| 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 |