Files
deerflow-code/frontend-web/src/pages/ChatsPage.tsx
T
吉鑫升andClaude Fable 5 3d6f35391e feat: 对话列表显示总数,SOUL 编辑区高度自适应收敛
- 新增 POST /threads/count:与 /threads/search 同一套过滤(标题搜索、
  系统会话排除),仅返回 {total};ThreadMetaStore 增加 count 方法
  (SQL 走 COUNT(*),JSON 过滤分批扫描计数)
- 对话页面搜索框下常驻显示「共 N 条对话」,分页条显示 当前页/总页数
  (useThreadsCount,仅随筛选条件变化,翻页不重复请求)
- 智能体右侧 SOUL 预览改为 40vh 上限自适应展开(不再全部展开);
  详情面板/卡片编辑/创建页的 SOUL 输入框统一为适中高度 + 可拖拽

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:26:44 +08:00

275 lines
9.6 KiB
TypeScript

"use client";
import {
ChevronLeft,
ChevronRight,
DownloadIcon,
Pin,
PinOff,
Star,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import {
useThreadsCount,
useThreadsPage,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
import {
isThreadFavorite,
isThreadPinned,
usePathOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 20;
export default function ChatsPage() {
const pathOfThread = usePathOfThread();
const { t } = useI18n();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [onlyFavorites, setOnlyFavorites] = useState(false);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
// Debounce the search box so each keystroke doesn't fire a backend query.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
// Reset to the first page whenever the filtered result set changes.
useEffect(() => {
setPage(1);
}, [debouncedSearch, onlyFavorites]);
// Conversations are paginated on the backend: each page is one bounded
// query (title search + favorites filter included), so a long history is
// never fetched in one go. Pinned conversations come from their own small
// query and are surfaced in a dedicated section on the first page only,
// and dropped from the paginated rows so they don't repeat on every page.
const favoriteFilter = useMemo(
() => (onlyFavorites ? { is_favorite: true } : {}),
[onlyFavorites],
);
const pinnedFilter = useMemo(
() => ({ ...favoriteFilter, is_pinned: true }),
[favoriteFilter],
);
const { data: pageData } = useThreadsPage({
page,
pageSize: PAGE_SIZE,
search: debouncedSearch,
metadata: favoriteFilter,
});
const { data: pinnedData } = useThreadsPage({
page: 1,
pageSize: 100,
search: debouncedSearch,
metadata: pinnedFilter,
});
const { data: totalCount } = useThreadsCount({
search: debouncedSearch,
metadata: favoriteFilter,
});
const pinnedThreads = pinnedData?.threads ?? [];
const pagedThreads = useMemo(
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
[pageData?.threads],
);
const currentPage = page;
const hasMore = pageData?.hasMore ?? false;
// Page windows are sliced over the raw (pinned-inclusive) row order, so the
// page count derives from the raw total as well.
const totalPages =
totalCount != null ? Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) : null;
const renderThread = (thread: AgentThread) => {
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
className="group/chat-row relative flex flex-col gap-2 border-b"
>
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
<div className="flex items-center gap-1.5">
{pinned && (
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</div>
{thread.updated_at && (
<div className="text-muted-foreground mt-2 text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</Link>
{/* Row actions — kept out of the Link so clicks don't navigate. */}
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
<Button
variant="ghost"
size="icon"
className="size-8"
title={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={cn(
"size-4",
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
title={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)}
>
{pinned ? (
<PinOff className="size-4 text-muted-foreground" />
) : (
<Pin className="size-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
);
};
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
variant={onlyFavorites ? "outline" : "secondary"}
size="sm"
onClick={() => setOnlyFavorites(false)}
>
{t.chats.allChats}
</Button>
<Button
variant={onlyFavorites ? "secondary" : "outline"}
size="sm"
onClick={() => setOnlyFavorites(true)}
>
<Star className="size-4" />
{t.chats.favorites}
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{totalCount != null && (
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
共 {totalCount} 条对话
</div>
)}
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{currentPage === 1 && pinnedThreads.length > 0 && (
<>
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection}
</div>
{pinnedThreads.map(renderThread)}
</>
)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
</div>
)}
{(currentPage > 1 || hasMore) && (
<div className="flex items-center justify-center gap-3 py-6">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{totalPages != null
? `${currentPage} / ${totalPages}`
: currentPage}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}