feat: 对话历史页收纳会商/写作/深度研究 Tab,侧栏移除重复入口
ChatsPage 新增四类历史 Tab 与统一搜索筛选 UI;简洁模式侧栏不再展示会商与写作列表;深度研究页支持 ?session= 深链打开指定记录。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
36653c53a6
commit
875001a8c0
9 files changed
+612
-669
No files matched your search
@@ -1,18 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
DownloadIcon,
|
||||
FilePenLine,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
PinOff,
|
||||
Search,
|
||||
Star,
|
||||
type LucideIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, 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 {
|
||||
@@ -43,8 +49,66 @@ import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab";
|
||||
import { WritingHistoryTab } from "./chats-history/WritingHistoryTab";
|
||||
import { ResearchHistoryTab } from "./chats-history/ResearchHistoryTab";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 「对话历史」页的四个历史 Tab:对话(通用问答/智能体会话)之外,把多智能体
|
||||
* 会商、AI写作、深度研究的记录也收纳进来(简洁模式侧栏不再展示后两者的入口,
|
||||
* 统一从这里查看)。
|
||||
*/
|
||||
type HistoryTab = "chats" | "roundtable" | "writing" | "research";
|
||||
type ChatFilter = "all" | "favorites" | "pinned";
|
||||
|
||||
const HISTORY_TABS: {
|
||||
id: HistoryTab;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}[] = [
|
||||
{
|
||||
id: "chats",
|
||||
label: "对话",
|
||||
description: "普通问答与智能体会话",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
id: "roundtable",
|
||||
label: "多智能体会商",
|
||||
description: "多角色协作研讨",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
id: "writing",
|
||||
label: "AI写作",
|
||||
description: "写作任务与草稿",
|
||||
icon: FilePenLine,
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
label: "深度研究",
|
||||
description: "研究任务与报告",
|
||||
icon: Search,
|
||||
},
|
||||
];
|
||||
|
||||
/** 非对话 Tab 的搜索占位文案(对话 Tab 沿用 i18n 的 t.chats.searchChats)。 */
|
||||
const TAB_SEARCH_PLACEHOLDER: Record<Exclude<HistoryTab, "chats">, string> = {
|
||||
roundtable: "搜索会商记录…",
|
||||
writing: "搜索写作记录…",
|
||||
research: "搜索研究记录…",
|
||||
};
|
||||
|
||||
const TAB_TOTAL_UNIT: Record<HistoryTab, string> = {
|
||||
chats: "条对话",
|
||||
roundtable: "条会商记录",
|
||||
writing: "篇写作",
|
||||
research: "项研究",
|
||||
};
|
||||
|
||||
export default function ChatsPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
@@ -54,11 +118,14 @@ export default function ChatsPage({
|
||||
const { t } = useI18n();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("chats");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
|
||||
const [tabTotal, setTabTotal] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
@@ -73,26 +140,41 @@ export default function ChatsPage({
|
||||
// Reset to the first page whenever the filtered result set changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, onlyFavorites]);
|
||||
}, [debouncedSearch, chatFilter]);
|
||||
|
||||
// 切换 Tab 时回到第一页并清空旧 Tab 上报的计数。
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setTabTotal(null);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleTabTotalChange = useCallback((total: number) => {
|
||||
setTabTotal(total);
|
||||
}, []);
|
||||
|
||||
// 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 threadFilter = useMemo(
|
||||
() =>
|
||||
chatFilter === "favorites"
|
||||
? { is_favorite: true }
|
||||
: chatFilter === "pinned"
|
||||
? { is_pinned: true }
|
||||
: {},
|
||||
[chatFilter],
|
||||
);
|
||||
const pinnedFilter = useMemo(
|
||||
() => ({ ...favoriteFilter, is_pinned: true }),
|
||||
[favoriteFilter],
|
||||
() => ({ ...threadFilter, is_pinned: true }),
|
||||
[threadFilter],
|
||||
);
|
||||
const { data: pageData } = useThreadsPage({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
metadata: favoriteFilter,
|
||||
metadata: threadFilter,
|
||||
});
|
||||
const { data: pinnedData } = useThreadsPage({
|
||||
page: 1,
|
||||
@@ -102,13 +184,28 @@ export default function ChatsPage({
|
||||
});
|
||||
const { data: totalCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: favoriteFilter,
|
||||
metadata: threadFilter,
|
||||
});
|
||||
const { data: allThreadsCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: {},
|
||||
});
|
||||
const { data: favoriteThreadsCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: { is_favorite: true },
|
||||
});
|
||||
const { data: pinnedThreadsCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: { is_pinned: true },
|
||||
});
|
||||
|
||||
const pinnedThreads = pinnedData?.threads ?? [];
|
||||
const pinnedThreads = chatFilter === "pinned" ? [] : (pinnedData?.threads ?? []);
|
||||
const pagedThreads = useMemo(
|
||||
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
|
||||
[pageData?.threads],
|
||||
() =>
|
||||
(pageData?.threads ?? []).filter(
|
||||
(thread) => chatFilter === "pinned" || !isThreadPinned(thread),
|
||||
),
|
||||
[chatFilter, pageData?.threads],
|
||||
);
|
||||
const currentPage = page;
|
||||
const hasMore = pageData?.hasMore ?? false;
|
||||
@@ -123,46 +220,67 @@ export default function ChatsPage({
|
||||
return (
|
||||
<div
|
||||
key={thread.thread_id}
|
||||
className="group/chat-row relative flex flex-col gap-2 border-b"
|
||||
className={cn(
|
||||
"group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5",
|
||||
pinned
|
||||
? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm"
|
||||
: "border-neutral-200/80 bg-white hover:border-red-200 hover:bg-red-50/20 hover:shadow-xs",
|
||||
)}
|
||||
>
|
||||
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0 rounded-lg sm:mt-0",
|
||||
favorited
|
||||
? "bg-amber-50 text-amber-500 hover:bg-amber-100 hover:text-amber-600"
|
||||
: "text-muted-foreground/45 hover:bg-muted hover:text-muted-foreground",
|
||||
)}
|
||||
title={favorited ? t.common.unfavorite : t.common.favorite}
|
||||
aria-label={favorited ? t.common.unfavorite : t.common.favorite}
|
||||
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
|
||||
>
|
||||
<Star className={cn("size-4", favorited && "fill-current")} />
|
||||
</Button>
|
||||
<Link
|
||||
href={pathOfThread(thread)}
|
||||
className="min-w-0 flex-1 rounded-lg p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{pinned && (
|
||||
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="inline-flex items-center gap-1 rounded border border-red-200/50 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
|
||||
<Pin className="size-3 rotate-45 text-red-600" />
|
||||
置顶
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-red-200/70 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
|
||||
<MessageSquare className="size-3 text-red-600" />
|
||||
对话
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-neutral-900 transition-colors group-hover/chat-row:text-red-700 sm:text-base">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
</div>
|
||||
{thread.updated_at && (
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground sm:hidden">
|
||||
<Clock className="size-3" />
|
||||
{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">
|
||||
{thread.updated_at && (
|
||||
<div className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
|
||||
<Clock className="size-3" />
|
||||
<span>{formatTimeAgo(thread.updated_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm: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"
|
||||
size="icon-sm"
|
||||
className="size-8"
|
||||
title={pinned ? t.common.unpin : t.common.pin}
|
||||
aria-label={pinned ? t.common.unpin : t.common.pin}
|
||||
onClick={() => togglePin(thread.thread_id, !pinned)}
|
||||
>
|
||||
{pinned ? (
|
||||
@@ -177,6 +295,9 @@ export default function ChatsPage({
|
||||
};
|
||||
|
||||
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
|
||||
// 头部计数:对话 Tab 用后端总数,其余 Tab 用组件回传的过滤后条数。
|
||||
const displayTotal = activeTab === "chats" ? totalCount : tabTotal;
|
||||
const activeTabConfig = HISTORY_TABS.find((tab) => tab.id === activeTab)!;
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
@@ -192,93 +313,260 @@ export default function ChatsPage({
|
||||
}
|
||||
/>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="flex size-full min-w-0 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)}
|
||||
<div className="relative flex w-full max-w-5xl items-center justify-center px-4 sm:px-6">
|
||||
<div
|
||||
className="inline-flex max-w-full overflow-x-auto rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
|
||||
role="tablist"
|
||||
aria-label="历史记录类型"
|
||||
>
|
||||
<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} 条对话
|
||||
{HISTORY_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`history-tab-${tab.id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex shrink-0 select-none items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
|
||||
isActive
|
||||
? "font-semibold text-red-700 shadow-xs"
|
||||
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
|
||||
)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`history-panel-${tab.id}`}
|
||||
title={tab.description}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-xl border border-red-200 bg-red-50/90 shadow-xs"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
<span>{tab.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "chats" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-4 sm:right-6"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.threadShare.importChats}
|
||||
</Button>
|
||||
)}
|
||||
</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}
|
||||
<ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col px-4 sm:px-6">
|
||||
<section
|
||||
id={`history-panel-${activeTab}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`history-tab-${activeTab}`}
|
||||
className="space-y-4 rounded-2xl border border-red-100 bg-white p-4 shadow-xs sm:p-5"
|
||||
>
|
||||
<div className="w-full space-y-3.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{activeTab === "chats" ? (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" role="group" aria-label="对话筛选">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
|
||||
chatFilter === "all"
|
||||
? "bg-red-600 font-semibold text-white shadow-xs"
|
||||
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700",
|
||||
)}
|
||||
aria-pressed={chatFilter === "all"}
|
||||
onClick={() => setChatFilter("all")}
|
||||
>
|
||||
<span>全部</span>
|
||||
{allThreadsCount != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-px font-mono text-[11px]",
|
||||
chatFilter === "all"
|
||||
? "bg-red-700 text-white"
|
||||
: "bg-neutral-100 text-neutral-500",
|
||||
)}
|
||||
>
|
||||
{allThreadsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30",
|
||||
chatFilter === "favorites"
|
||||
? "border-amber-300 bg-amber-50 font-semibold text-amber-700 shadow-xs"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900",
|
||||
)}
|
||||
aria-pressed={chatFilter === "favorites"}
|
||||
onClick={() => setChatFilter("favorites")}
|
||||
>
|
||||
<Star className={cn("size-3.5", chatFilter === "favorites" ? "fill-amber-500 text-amber-500" : "text-neutral-400")} />
|
||||
<span>收藏</span>
|
||||
{favoriteThreadsCount != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-px font-mono text-[11px]",
|
||||
chatFilter === "favorites"
|
||||
? "bg-amber-200 text-amber-900"
|
||||
: "bg-neutral-100 text-neutral-500",
|
||||
)}
|
||||
>
|
||||
{favoriteThreadsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{pinnedThreadsCount != null && pinnedThreadsCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
|
||||
chatFilter === "pinned"
|
||||
? "border-red-200 bg-red-50 font-semibold text-red-700 shadow-xs"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-red-50/50 hover:text-red-700",
|
||||
)}
|
||||
aria-pressed={chatFilter === "pinned"}
|
||||
onClick={() => setChatFilter("pinned")}
|
||||
>
|
||||
<Pin className="size-3.5 rotate-45 text-red-600" />
|
||||
<span>置顶</span>
|
||||
<span className="rounded-full bg-red-100 px-1.5 py-px font-mono text-[11px] font-medium text-red-800">
|
||||
{pinnedThreadsCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 truncate text-sm font-medium text-neutral-500">
|
||||
{activeTabConfig.description}
|
||||
</div>
|
||||
)}
|
||||
<label className="relative min-w-0 flex-1 md:max-w-md">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{activeTab === "chats"
|
||||
? t.chats.searchChats
|
||||
: TAB_SEARCH_PLACEHOLDER[activeTab]}
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
|
||||
placeholder={
|
||||
activeTab === "chats"
|
||||
? "搜索对话标题、关键词或标签…"
|
||||
: TAB_SEARCH_PLACEHOLDER[activeTab]
|
||||
}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30"
|
||||
aria-label="清空搜索"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
|
||||
{displayTotal != null && (
|
||||
<span className="font-medium">
|
||||
共 <strong className="font-bold text-red-700">{displayTotal}</strong>{" "}
|
||||
{TAB_TOTAL_UNIT[activeTab]}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{activeTab === "chats" && (
|
||||
<div className="space-y-3 pt-5">
|
||||
{currentPage === 1 && pinnedThreads.map(renderThread)}
|
||||
{pagedThreads.map(renderThread)}
|
||||
{isEmpty && (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
{chatFilter === "favorites"
|
||||
? t.chats.noFavorites
|
||||
: chatFilter === "pinned"
|
||||
? "暂无置顶对话"
|
||||
: t.chats.noChats}
|
||||
</div>
|
||||
)}
|
||||
{(currentPage > 1 || hasMore) && (
|
||||
<div className="flex items-center justify-center gap-3 border-t border-primary/10 pt-5">
|
||||
<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>
|
||||
)}
|
||||
{activeTab === "roundtable" && (
|
||||
<div className="pt-5">
|
||||
<RoundtableHistoryTab
|
||||
query={search}
|
||||
onTotalChange={handleTabTotalChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "writing" && (
|
||||
<div className="pt-5">
|
||||
<WritingHistoryTab
|
||||
query={search}
|
||||
onTotalChange={handleTabTotalChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "research" && (
|
||||
<div className="pt-5">
|
||||
<ResearchHistoryTab
|
||||
query={search}
|
||||
onTotalChange={handleTabTotalChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
HistoryRow,
|
||||
HistoryRowIconButton,
|
||||
HistoryStatusPill,
|
||||
HistoryTypeBadge,
|
||||
RenameIcon,
|
||||
} from "./history-tab-shared";
|
||||
|
||||
@@ -115,51 +117,59 @@ export function ResearchHistoryTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
{filtered.map((session) => {
|
||||
const status = RESEARCH_STATUS[session.status] ?? {
|
||||
label: session.status,
|
||||
className: "text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<HistoryRow
|
||||
key={session.id}
|
||||
title={desensitize(session.title || session.query || "未命名研究")}
|
||||
meta={
|
||||
<>
|
||||
<HistoryStatusPill
|
||||
label={status.label}
|
||||
className={status.className}
|
||||
/>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
{session.sourceCount > 0 && (
|
||||
<>
|
||||
<span>{session.sourceCount} 条来源</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
</>
|
||||
)}
|
||||
<span>{formatRelative(session.updatedAt ?? session.createdAt ?? "")}</span>
|
||||
</>
|
||||
}
|
||||
onOpen={() => openSession(session)}
|
||||
>
|
||||
<HistoryRowIconButton
|
||||
title="重命名"
|
||||
onClick={() => {
|
||||
setRenameId(session.id);
|
||||
setRenameTitle(session.title || session.query || "");
|
||||
}}
|
||||
<div>
|
||||
{filtered.map((session) => {
|
||||
const status = RESEARCH_STATUS[session.status] ?? {
|
||||
label: session.status,
|
||||
className: "text-muted-foreground",
|
||||
};
|
||||
const timestamp = session.updatedAt ?? session.createdAt ?? null;
|
||||
return (
|
||||
<HistoryRow
|
||||
key={session.id}
|
||||
badge={<HistoryTypeBadge icon={Search} label="深度研究" />}
|
||||
title={desensitize(session.title || session.query || "未命名研究")}
|
||||
meta={
|
||||
<>
|
||||
<HistoryStatusPill
|
||||
label={status.label}
|
||||
className={status.className}
|
||||
/>
|
||||
{session.sourceCount > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{session.sourceCount} 条来源</span>
|
||||
</>
|
||||
)}
|
||||
{timestamp && (
|
||||
<>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{formatRelative(timestamp)}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onOpen={() => openSession(session)}
|
||||
>
|
||||
<RenameIcon />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton
|
||||
title="删除"
|
||||
onClick={() => void handleDelete(session)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</HistoryRowIconButton>
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
<HistoryRowIconButton
|
||||
title="重命名"
|
||||
onClick={() => {
|
||||
setRenameId(session.id);
|
||||
setRenameTitle(session.title || session.query || "");
|
||||
}}
|
||||
>
|
||||
<RenameIcon />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton
|
||||
title="删除"
|
||||
onClick={() => void handleDelete(session)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</HistoryRowIconButton>
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<HistoryRenameDialog
|
||||
open={renameId !== null}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Share2 } from "lucide-react";
|
||||
import { Bot, Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
HistoryRow,
|
||||
HistoryRowIconButton,
|
||||
HistoryStatusPill,
|
||||
HistoryTypeBadge,
|
||||
RenameIcon,
|
||||
} from "./history-tab-shared";
|
||||
|
||||
@@ -119,45 +120,48 @@ export function RoundtableHistoryTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
{filtered.map((draft) => {
|
||||
const status = statusOf(draft);
|
||||
return (
|
||||
<HistoryRow
|
||||
key={draft.id}
|
||||
title={desensitize(draft.title || "未命名草稿")}
|
||||
meta={
|
||||
<>
|
||||
<HistoryStatusPill
|
||||
label={status.label}
|
||||
className={status.className}
|
||||
/>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{formatRelative(draft.updatedAt)}</span>
|
||||
</>
|
||||
}
|
||||
onOpen={() => openDraft(draft)}
|
||||
>
|
||||
<HistoryRowIconButton
|
||||
title="重命名"
|
||||
onClick={() => {
|
||||
setRenameId(draft.id);
|
||||
setRenameTitle(draft.title || "");
|
||||
}}
|
||||
<div>
|
||||
{filtered.map((draft) => {
|
||||
const status = statusOf(draft);
|
||||
return (
|
||||
<HistoryRow
|
||||
key={draft.id}
|
||||
badge={<HistoryTypeBadge icon={Bot} label="会商" />}
|
||||
title={desensitize(draft.title || "未命名草稿")}
|
||||
meta={
|
||||
<>
|
||||
<HistoryStatusPill
|
||||
label={status.label}
|
||||
className={status.className}
|
||||
/>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{formatRelative(draft.updatedAt)}</span>
|
||||
</>
|
||||
}
|
||||
onOpen={() => openDraft(draft)}
|
||||
>
|
||||
<RenameIcon />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton title="分享" onClick={() => setShareId(draft.id)}>
|
||||
<Share2 className="text-muted-foreground size-4" />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton
|
||||
title="删除"
|
||||
onClick={() => void handleDelete(draft)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</HistoryRowIconButton>
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
<HistoryRowIconButton
|
||||
title="重命名"
|
||||
onClick={() => {
|
||||
setRenameId(draft.id);
|
||||
setRenameTitle(draft.title || "");
|
||||
}}
|
||||
>
|
||||
<RenameIcon />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton title="分享" onClick={() => setShareId(draft.id)}>
|
||||
<Share2 className="text-muted-foreground size-4" />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton
|
||||
title="删除"
|
||||
onClick={() => void handleDelete(draft)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</HistoryRowIconButton>
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<HistoryRenameDialog
|
||||
open={renameId !== null}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FilePenLine } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
HistoryRow,
|
||||
HistoryRowIconButton,
|
||||
HistoryStatusPill,
|
||||
HistoryTypeBadge,
|
||||
RenameIcon,
|
||||
} from "./history-tab-shared";
|
||||
|
||||
@@ -100,42 +102,45 @@ export function WritingHistoryTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
{filtered.map((session) => {
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? {
|
||||
label: session.status,
|
||||
color: "text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<HistoryRow
|
||||
key={session.id}
|
||||
title={desensitize(session.title)}
|
||||
meta={
|
||||
<>
|
||||
<HistoryStatusPill label={label} className={color} />
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{formatRelative(session.updated_at)}</span>
|
||||
</>
|
||||
}
|
||||
onOpen={() => openSession(session)}
|
||||
>
|
||||
<HistoryRowIconButton
|
||||
title="重命名"
|
||||
onClick={() => {
|
||||
setRenameId(session.id);
|
||||
setRenameTitle(session.title);
|
||||
}}
|
||||
<div>
|
||||
{filtered.map((session) => {
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? {
|
||||
label: session.status,
|
||||
color: "text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<HistoryRow
|
||||
key={session.id}
|
||||
badge={<HistoryTypeBadge icon={FilePenLine} label="AI写作" />}
|
||||
title={desensitize(session.title)}
|
||||
meta={
|
||||
<>
|
||||
<HistoryStatusPill label={label} className={color} />
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{formatRelative(session.updated_at)}</span>
|
||||
</>
|
||||
}
|
||||
onOpen={() => openSession(session)}
|
||||
>
|
||||
<RenameIcon />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton
|
||||
title="删除"
|
||||
onClick={() => void handleDelete(session)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</HistoryRowIconButton>
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
<HistoryRowIconButton
|
||||
title="重命名"
|
||||
onClick={() => {
|
||||
setRenameId(session.id);
|
||||
setRenameTitle(session.title);
|
||||
}}
|
||||
>
|
||||
<RenameIcon />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton
|
||||
title="删除"
|
||||
onClick={() => void handleDelete(session)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</HistoryRowIconButton>
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<HistoryRenameDialog
|
||||
open={renameId !== null}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -34,37 +34,58 @@ export function HistoryStatusPill({
|
||||
);
|
||||
}
|
||||
|
||||
/** 历史卡片左上角的类型标签,与对话卡片的「对话」标签保持同一视觉层级。 */
|
||||
export function HistoryTypeBadge({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-red-200/70 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
|
||||
<Icon className="size-3 text-red-600" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转
|
||||
* 对应工作台,由页面挂载时消费),操作按钮经 children 传入。
|
||||
*/
|
||||
export function HistoryRow({
|
||||
badge,
|
||||
title,
|
||||
meta,
|
||||
onOpen,
|
||||
children,
|
||||
}: {
|
||||
badge?: ReactNode;
|
||||
title: ReactNode;
|
||||
meta: ReactNode;
|
||||
onOpen: () => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="group/chat-row relative flex flex-col gap-2 border-b">
|
||||
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:border-red-200 hover:bg-red-50/20 hover:shadow-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="block w-full p-4 pr-24 text-left"
|
||||
className="block w-full p-3 pr-28 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:p-3.5 sm:pr-32"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate">{title}</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{badge}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold transition-colors group-hover/chat-row:text-red-700 sm:text-base">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-sm">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{meta}
|
||||
</div>
|
||||
</button>
|
||||
{/* Row actions — kept out of the open button so clicks don't trigger it. */}
|
||||
<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">
|
||||
<div className="absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1 rounded-lg bg-white/90 opacity-100 shadow-sm backdrop-blur-sm transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Select as TSelect } from "tdesign-react";
|
||||
@@ -444,7 +445,12 @@ export default function DeepResearchWorkbench() {
|
||||
}
|
||||
|
||||
function DeepResearchWorkbenchInner() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// 「?session=<id>」深链:对话历史页「深度研究」Tab 等外部入口直接定位到某条
|
||||
// 研究记录。仅作初始选中(首帧读取一次);之后的切换仍走页内状态,不回写 URL。
|
||||
const [searchParams] = useSearchParams();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() =>
|
||||
searchParams.get("session"),
|
||||
);
|
||||
const [mode, setMode] = useState<ResearchMode>("basic");
|
||||
const [generateImages, setGenerateImages] = useState(false);
|
||||
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);
|
||||
|
||||
Reference in new issue
Block a user