PicoBot/web/src/components/Sidebar/TopicList.tsx
oudecheng 0159227828 perf: 第二批性能修复——blocking 线程池隔离、HTTP 客户端复用、前端 memo 与流式节流
- 同步阻塞操作(附件处理、历史加载、scheduler/memory_search 的 SQLite 调用)
  移入 spawn_blocking,避免占用 async worker
- LLM Provider reqwest::Client 按超时配置缓存复用,减少 TLS/连接开销
- agent loop:图片过滤加廉价预判避免全量深拷贝;请求克隆改借用;工具定义 Arc 化
- 定向 COUNT/LIMIT 1 查询替代全量加载计数(wait_coordinator、task session 重建)
- 前端:面板/侧栏/聊天组件 memo 化;merged_tool 对象按值复用缓存;
  流式 delta rAF 节流批量 flush;useMemo 缓存分组排序结果
2026-08-18 06:55:21 +08:00

363 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' });
}
}
// memoprops 全部稳定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<string | null>(null);
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const editInputRef = useRef<HTMLInputElement>(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<HTMLDivElement>(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 (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--border-color)] px-4 py-3">
<h2 className="font-semibold text-[var(--text-primary)] flex items-center gap-2 text-sm">
<Layers className="h-4 w-4 text-[var(--accent-cyan)]" />
{topics.length > 0 && (
<span className="text-xs text-[var(--text-muted)]">({topics.length})</span>
)}
</h2>
{isReadOnly ? (
<button
onClick={onRefresh}
disabled={!sessionId}
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition-all ${
!sessionId
? 'text-[var(--text-muted)] cursor-not-allowed'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)]'
}`}
title="刷新话题列表"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={onCreateTopic}
disabled={!sessionId}
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
!sessionId
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
: 'bg-[var(--accent-cyan)] text-white hover:opacity-90'
}`}
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
{/* Topics 列表 */}
<div ref={listRef} className="flex-1 overflow-y-auto p-3">
{!sessionId ? (
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>...</p>
</div>
) : topics.length === 0 ? (
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p></p>
<p className="text-xs mt-1">"新建"</p>
</div>
) : (
<div className="space-y-1">
{pagedTopics.map((topic, index) => (
<div key={topic.id} className="group relative">
{editingTopicId === topic.id ? (
// 编辑模式:内联输入框 + 提交/取消按钮
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
<form
onSubmit={(e) => {
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"
>
<input
ref={editInputRef}
value={editingTitle}
onChange={(e) => 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}
/>
<button
type="submit"
onMouseDown={(e) => e.preventDefault()}
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors shrink-0"
title="确认 (Enter)"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={cancelEdit}
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors shrink-0"
title="取消 (Esc)"
>
<X className="h-3.5 w-3.5" />
</button>
</form>
) : (
<>
<button
onClick={() => onSwitchTopic(topic.id)}
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-colors ${
topic.id === currentTopicId
? 'bg-[var(--overlay-subtle)] border border-[var(--border-color)]'
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-[var(--text-muted)] w-4">
{currentPage * pageSize + index + 1}
</span>
<div className="min-w-0 flex-1">
<div
className={`truncate font-medium ${
topic.id === currentTopicId
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
}`}
>
{topic.description || topic.title}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5">
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Hash className="h-3 w-3" />
{topic.message_count}
</span>
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(topic.updated_at)}
</span>
</div>
</div>
{topic.id === currentTopicId && (
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] mt-1.5" />
)}
</div>
</button>
{/* 编辑/删除按钮 — 悬停可见 */}
<div className="absolute top-2.5 right-2.5">
{confirmDeleteId === topic.id ? (
<span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in">
<span className="text-xs text-[rgb(242,90,90)] whitespace-nowrap">
?
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTopic(topic.id);
setConfirmDeleteId(null);
}}
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors"
title="确认"
>
<Check className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(null);
}}
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors"
title="取消"
>
<X className="h-3 w-3" />
</button>
</span>
) : (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
startEdit(topic);
}}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="重命名话题"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(topic.id);
}}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除话题"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{/* Pagination bar */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2">
<button
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs text-[var(--text-muted)] min-w-[3rem] text-center select-none">
{currentPage + 1} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage >= totalPages - 1}
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
);
});