import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react'; import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X, ChevronLeft, ChevronRight, Edit2, } from 'lucide-react'; import type { Topic } from '../../types/protocol'; interface TopicListProps { sessionId: string | null; topics: Topic[]; currentTopicId: string | null; isReadOnly: boolean; onCreateTopic: () => void; onRefresh: () => void; onSwitchTopic: (topicId: string) => void; onDeleteTopic: (topicId: string) => void; onRenameTopic: (topicId: string, title: string) => void; } function formatTime(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); if (diffDays === 0) { return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } else if (diffDays === 1) { return '昨天'; } else if (diffDays < 7) { return `${diffDays}天前`; } else { return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); } } // memo:props 全部稳定(topics 仅在 topic_list 消息到达时换引用、回调均 useCallback), // 流式期间 App 每帧重渲染时跳过话题列表重渲染与分页重算。 export const TopicList = memo(function TopicList({ sessionId, topics, currentTopicId, isReadOnly, onCreateTopic, onRefresh, onSwitchTopic, onDeleteTopic, onRenameTopic, }: TopicListProps) { const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [editingTopicId, setEditingTopicId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const editInputRef = useRef(null); // 进入编辑模式时自动聚焦 input useEffect(() => { if (editingTopicId && editInputRef.current) { editInputRef.current.focus(); editInputRef.current.select(); } }, [editingTopicId]); const startEdit = useCallback((topic: Topic) => { setConfirmDeleteId(null); setEditingTopicId(topic.id); // 预填当前显示值(description 优先,与列表显示逻辑一致) setEditingTitle(topic.description || topic.title); }, []); const cancelEdit = useCallback(() => { setEditingTopicId(null); setEditingTitle(''); }, []); const commitEdit = useCallback(() => { const trimmed = editingTitle.trim(); if (!trimmed || !editingTopicId) { cancelEdit(); return; } onRenameTopic(editingTopicId, trimmed); setEditingTopicId(null); setEditingTitle(''); }, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]); // Pagination — dynamically sized to fill one screen without scrolling const ESTIMATED_ITEM_HEIGHT = 64; // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px) const LIST_PADDING = 24; // p-3 top + bottom const [pageSize, setPageSize] = useState(8); // fallback before measurement const [currentPage, setCurrentPage] = useState(0); const listRef = useRef(null); const measurePageSize = useCallback(() => { const el = listRef.current; if (!el) return; const available = el.clientHeight - LIST_PADDING; setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1)); }, []); useEffect(() => { measurePageSize(); const el = listRef.current; if (!el) return; const observer = new ResizeObserver(() => measurePageSize()); observer.observe(el); return () => observer.disconnect(); }, [measurePageSize]); const totalPages = useMemo( () => Math.max(1, Math.ceil(topics.length / pageSize)), [topics.length, pageSize], ); const pagedTopics = useMemo( () => topics.slice(currentPage * pageSize, (currentPage + 1) * pageSize), [topics, currentPage, pageSize], ); // Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page) useEffect(() => { if (currentPage >= totalPages) { setCurrentPage(Math.max(0, totalPages - 1)); } }, [currentPage, totalPages]); return (
{/* Header */}

话题列表 {topics.length > 0 && ( ({topics.length}) )}

{isReadOnly ? ( ) : ( )}
{/* Topics 列表 */}
{!sessionId ? (

等待连接...

) : topics.length === 0 ? (

暂无话题

点击上方"新建"创建话题

) : (
{pagedTopics.map((topic, index) => (
{editingTopicId === topic.id ? ( // 编辑模式:内联输入框 + 提交/取消按钮 // 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
{ e.preventDefault(); commitEdit(); }} className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40" > setEditingTitle(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); } }} onBlur={cancelEdit} className="flex-1 min-w-0 bg-transparent text-sm text-[var(--text-primary)] outline-none border-none focus:ring-0 placeholder:text-[var(--text-muted)]" placeholder="话题标题" maxLength={120} />
) : ( <> {/* 编辑/删除按钮 — 悬停可见 */}
{confirmDeleteId === topic.id ? ( 确认删除? ) : (
)}
)}
))}
)}
{/* Pagination bar */} {totalPages > 1 && (
{currentPage + 1} / {totalPages}
)}
); });