feat: 对话历史页收纳会商/写作/深度研究 Tab,侧栏移除重复入口

ChatsPage 新增四类历史 Tab 与统一搜索筛选 UI;简洁模式侧栏不再展示会商与写作列表;深度研究页支持 ?session= 深链打开指定记录。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-28 15:43:02 +08:00
1 parent 36653c53a6
commit 875001a8c0
9 files changed
+583 -640

No files matched your search

@@ -1,267 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
import { isIMEComposing } from "@/lib/ime";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
function openDraft(
draft: DraftMeta,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
window.dispatchEvent(
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
);
} else {
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
sessionStorage.setItem("roundtable:pending-draft", draft.id);
router.push(ROUNDTABLE_PATH);
}
}
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
function StatusBadge({ draft }: { draft: DraftMeta }) {
if (draft.status === "running") {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
进行中
</span>
);
}
if (draft.status === "awaiting_input") {
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
}
if (draft.status === "error") {
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
}
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
return (
<span className="text-[10px] font-medium text-muted-foreground">
{draft.chainTitle || "自由模式"}
</span>
);
}
export function RecentRoundtableList() {
const router = useRouter();
const pathname = usePathname();
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
);
// 重命名对话框状态。
const [renameId, setRenameId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// 分享对话框 —— 正在分享的草稿 id。
const [shareId, setShareId] = useState<string | null>(null);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
const handleRenameSubmit = useCallback(async () => {
const id = renameId;
const title = renameValue.trim();
if (!id || !title) {
setRenameId(null);
return;
}
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
setRenameValue("");
}
}, [renameId, renameValue, rename]);
const handleDelete = useCallback(
async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
},
[remove],
);
if (!loading && drafts.length === 0) return null;
const isOnPage = pathname === ROUNDTABLE_PATH;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>多智能体会商</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && drafts.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
drafts.slice(0, 10).map((draft) => (
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openDraft(draft, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{draft.title || "未命名草稿"}
</span>
<div className="flex items-center gap-1 mt-0.5">
<StatusBadge draft={draft} />
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background top-2"
>
<MoreHorizontal />
<span className="sr-only">更多</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem
onSelect={() => {
setRenameId(draft.id);
setRenameValue(draft.title || "");
}}
>
<Pencil className="text-muted-foreground" />
<span>重命名</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground" />
<span>分享</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
<Trash2 className="text-muted-foreground" />
<span>删除</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* 重命名对话框 */}
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>重命名会商</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameId(null)}>
取消
</Button>
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享对话框 */}
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(null);
}}
/>
</>
);
}
@@ -1,122 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
function openSession(
session: AIWritingSessionSummary,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在 AI 写作页面,直接通过自定义事件通知面板
window.dispatchEvent(
new CustomEvent("ai-writing:open-session", { detail: session }),
);
} else {
// 不在该页面,把 session 存入 sessionStorage 后跳转
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
router.push(AI_WRITING_PATH);
}
}
export function RecentWritingList() {
const router = useRouter();
const pathname = usePathname();
const { sessions, loading, refresh } = useAIWritingSessions();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
if (!loading && sessions.length === 0) return null;
const isOnPage = pathname === AI_WRITING_PATH;
return (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -14,8 +14,6 @@ import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar"; import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu"; import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -123,10 +121,10 @@ export function WorkspaceSidebar({
compact={compactChrome} compact={compactChrome}
/> />
{isSidebarOpen && <RecentChatList compact={compactChrome} />} {isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐: {/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */} (/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
{isSidebarOpen && !compactChrome && <RecentRoundtableList />} 无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
{isSidebarOpen && !compactChrome && <RecentWritingList />} 全量模式本就不展示。 */}
</SidebarContent> </SidebarContent>
{!compactChrome && ( {!compactChrome && (
<SidebarFooter> <SidebarFooter>
+407 -119
View File
@@ -1,18 +1,24 @@
"use client"; "use client";
import { import {
Bot,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock,
DownloadIcon, DownloadIcon,
FilePenLine,
MessageSquare,
Pin, Pin,
PinOff, PinOff,
Search,
Star, Star,
type LucideIcon,
X,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs"; import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import { import {
@@ -43,8 +49,66 @@ import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; 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; 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({ export default function ChatsPage({
compactChrome = false, compactChrome = false,
}: { }: {
@@ -54,11 +118,14 @@ export default function ChatsPage({
const { t } = useI18n(); const { t } = useI18n();
const { toggle: togglePin } = useToggleThreadPin(); const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite(); const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [activeTab, setActiveTab] = useState<HistoryTab>("chats");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [onlyFavorites, setOnlyFavorites] = useState(false); const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`; 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. // Reset to the first page whenever the filtered result set changes.
useEffect(() => { useEffect(() => {
setPage(1); 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 // Conversations are paginated on the backend: each page is one bounded
// query (title search + favorites filter included), so a long history is // query (title search + favorites filter included), so a long history is
// never fetched in one go. Pinned conversations come from their own small // 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, // 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. // and dropped from the paginated rows so they don't repeat on every page.
const favoriteFilter = useMemo( const threadFilter = useMemo(
() => (onlyFavorites ? { is_favorite: true } : {}), () =>
[onlyFavorites], chatFilter === "favorites"
? { is_favorite: true }
: chatFilter === "pinned"
? { is_pinned: true }
: {},
[chatFilter],
); );
const pinnedFilter = useMemo( const pinnedFilter = useMemo(
() => ({ ...favoriteFilter, is_pinned: true }), () => ({ ...threadFilter, is_pinned: true }),
[favoriteFilter], [threadFilter],
); );
const { data: pageData } = useThreadsPage({ const { data: pageData } = useThreadsPage({
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
search: debouncedSearch, search: debouncedSearch,
metadata: favoriteFilter, metadata: threadFilter,
}); });
const { data: pinnedData } = useThreadsPage({ const { data: pinnedData } = useThreadsPage({
page: 1, page: 1,
@@ -102,13 +184,28 @@ export default function ChatsPage({
}); });
const { data: totalCount } = useThreadsCount({ const { data: totalCount } = useThreadsCount({
search: debouncedSearch, 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( 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 currentPage = page;
const hasMore = pageData?.hasMore ?? false; const hasMore = pageData?.hasMore ?? false;
@@ -123,46 +220,67 @@ export default function ChatsPage({
return ( return (
<div <div
key={thread.thread_id} 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"> <Button
<div className="flex items-center gap-1.5"> 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 && ( {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))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
</div> </div>
{thread.updated_at && ( {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)} {formatTimeAgo(thread.updated_at)}
</div> </div>
)} )}
</Link> </Link>
{/* Row actions — kept out of the Link so clicks don't navigate. */} {thread.updated_at && (
<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="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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
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" className="size-8"
title={pinned ? t.common.unpin : t.common.pin} title={pinned ? t.common.unpin : t.common.pin}
aria-label={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)} onClick={() => togglePin(thread.thread_id, !pinned)}
> >
{pinned ? ( {pinned ? (
@@ -177,6 +295,9 @@ export default function ChatsPage({
}; };
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0; 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 ( return (
<WorkspaceContainer> <WorkspaceContainer>
@@ -192,93 +313,260 @@ export default function ChatsPage({
} }
/> />
<WorkspaceBody> <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"> <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="relative flex w-full max-w-5xl items-center justify-center px-4 sm:px-6">
<div className="flex items-center gap-1"> <div
<Button 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"
variant={onlyFavorites ? "outline" : "secondary"} role="tablist"
size="sm" aria-label="历史记录类型"
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" /> {HISTORY_TABS.map((tab) => {
{t.threadShare.importChats} const isActive = activeTab === tab.id;
</Button> const Icon = tab.icon;
</div>
<Input return (
type="search" <button
className="h-12 w-full max-w-(--container-width-md) text-xl" key={tab.id}
placeholder={t.chats.searchChats} id={`history-tab-${tab.id}`}
autoFocus type="button"
value={search} className={cn(
onChange={(e) => setSearch(e.target.value)} "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
{totalCount != null && ( ? "font-semibold text-red-700 shadow-xs"
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums"> : "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
共 {totalCount} 条对话 )}
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> </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> </header>
<main className="min-h-0 flex-1"> <main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4"> <ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col"> <div className="mx-auto flex w-full max-w-5xl flex-col px-4 sm:px-6">
{currentPage === 1 && pinnedThreads.length > 0 && ( <section
<> id={`history-panel-${activeTab}`}
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase"> role="tabpanel"
{t.chats.pinnedSection} 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> </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> </div>
)} {activeTab === "chats" && (
{(currentPage > 1 || hasMore) && ( <div className="space-y-3 pt-5">
<div className="flex items-center justify-center gap-3 py-6"> {currentPage === 1 && pinnedThreads.map(renderThread)}
<Button {pagedThreads.map(renderThread)}
variant="outline" {isEmpty && (
size="sm" <div className="text-muted-foreground py-16 text-center text-sm">
disabled={currentPage <= 1} {chatFilter === "favorites"
onClick={() => setPage((p) => Math.max(1, p - 1))} ? t.chats.noFavorites
> : chatFilter === "pinned"
<ChevronLeft className="size-4" /> ? "暂无置顶对话"
{t.chats.prevPage} : t.chats.noChats}
</Button> </div>
<span className="text-muted-foreground text-sm tabular-nums"> )}
{totalPages != null {(currentPage > 1 || hasMore) && (
? `${currentPage} / ${totalPages}` <div className="flex items-center justify-center gap-3 border-t border-primary/10 pt-5">
: currentPage} <Button
</span> variant="outline"
<Button size="sm"
variant="outline" disabled={currentPage <= 1}
size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={!hasMore} >
onClick={() => setPage((p) => p + 1)} <ChevronLeft className="size-4" />
> {t.chats.prevPage}
{t.chats.nextPage} </Button>
<ChevronRight className="size-4" /> <span className="text-muted-foreground text-sm tabular-nums">
</Button> {totalPages != null
</div> ? `${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> </div>
</ScrollArea> </ScrollArea>
</main> </main>
@@ -10,6 +10,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -26,6 +27,7 @@ import {
HistoryRow, HistoryRow,
HistoryRowIconButton, HistoryRowIconButton,
HistoryStatusPill, HistoryStatusPill,
HistoryTypeBadge,
RenameIcon, RenameIcon,
} from "./history-tab-shared"; } from "./history-tab-shared";
@@ -115,51 +117,59 @@ export function ResearchHistoryTab({
return ( return (
<> <>
{filtered.map((session) => { <div>
const status = RESEARCH_STATUS[session.status] ?? { {filtered.map((session) => {
label: session.status, const status = RESEARCH_STATUS[session.status] ?? {
className: "text-muted-foreground", label: session.status,
}; className: "text-muted-foreground",
return ( };
<HistoryRow const timestamp = session.updatedAt ?? session.createdAt ?? null;
key={session.id} return (
title={desensitize(session.title || session.query || "未命名研究")} <HistoryRow
meta={ key={session.id}
<> badge={<HistoryTypeBadge icon={Search} label="深度研究" />}
<HistoryStatusPill title={desensitize(session.title || session.query || "未命名研究")}
label={status.label} meta={
className={status.className} <>
/> <HistoryStatusPill
<span className="text-muted-foreground/60">·</span> label={status.label}
{session.sourceCount > 0 && ( className={status.className}
<> />
<span>{session.sourceCount} 条来源</span> {session.sourceCount > 0 && (
<span className="text-muted-foreground/60">·</span> <>
</> <span className="text-muted-foreground/60">·</span>
)} <span>{session.sourceCount} 条来源</span>
<span>{formatRelative(session.updatedAt ?? session.createdAt ?? "")}</span> </>
</> )}
} {timestamp && (
onOpen={() => openSession(session)} <>
> <span className="text-muted-foreground/60">·</span>
<HistoryRowIconButton <span>{formatRelative(timestamp)}</span>
title="重命名" </>
onClick={() => { )}
setRenameId(session.id); </>
setRenameTitle(session.title || session.query || ""); }
}} onOpen={() => openSession(session)}
> >
<RenameIcon /> <HistoryRowIconButton
</HistoryRowIconButton> title="重命名"
<HistoryRowIconButton onClick={() => {
title="删除" setRenameId(session.id);
onClick={() => void handleDelete(session)} setRenameTitle(session.title || session.query || "");
> }}
<DeleteIcon /> >
</HistoryRowIconButton> <RenameIcon />
</HistoryRow> </HistoryRowIconButton>
); <HistoryRowIconButton
})} title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog <HistoryRenameDialog
open={renameId !== null} open={renameId !== null}
@@ -12,7 +12,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Share2 } from "lucide-react"; import { Bot, Share2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs"; import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
@@ -27,6 +27,7 @@ import {
HistoryRow, HistoryRow,
HistoryRowIconButton, HistoryRowIconButton,
HistoryStatusPill, HistoryStatusPill,
HistoryTypeBadge,
RenameIcon, RenameIcon,
} from "./history-tab-shared"; } from "./history-tab-shared";
@@ -119,45 +120,48 @@ export function RoundtableHistoryTab({
return ( return (
<> <>
{filtered.map((draft) => { <div>
const status = statusOf(draft); {filtered.map((draft) => {
return ( const status = statusOf(draft);
<HistoryRow return (
key={draft.id} <HistoryRow
title={desensitize(draft.title || "未命名草稿")} key={draft.id}
meta={ badge={<HistoryTypeBadge icon={Bot} label="会商" />}
<> title={desensitize(draft.title || "未命名草稿")}
<HistoryStatusPill meta={
label={status.label} <>
className={status.className} <HistoryStatusPill
/> label={status.label}
<span className="text-muted-foreground/60">·</span> className={status.className}
<span>{formatRelative(draft.updatedAt)}</span> />
</> <span className="text-muted-foreground/60">·</span>
} <span>{formatRelative(draft.updatedAt)}</span>
onOpen={() => openDraft(draft)} </>
> }
<HistoryRowIconButton onOpen={() => openDraft(draft)}
title="重命名"
onClick={() => {
setRenameId(draft.id);
setRenameTitle(draft.title || "");
}}
> >
<RenameIcon /> <HistoryRowIconButton
</HistoryRowIconButton> title="重命名"
<HistoryRowIconButton title="分享" onClick={() => setShareId(draft.id)}> onClick={() => {
<Share2 className="text-muted-foreground size-4" /> setRenameId(draft.id);
</HistoryRowIconButton> setRenameTitle(draft.title || "");
<HistoryRowIconButton }}
title="删除" >
onClick={() => void handleDelete(draft)} <RenameIcon />
> </HistoryRowIconButton>
<DeleteIcon /> <HistoryRowIconButton title="分享" onClick={() => setShareId(draft.id)}>
</HistoryRowIconButton> <Share2 className="text-muted-foreground size-4" />
</HistoryRow> </HistoryRowIconButton>
); <HistoryRowIconButton
})} title="删除"
onClick={() => void handleDelete(draft)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog <HistoryRenameDialog
open={renameId !== null} open={renameId !== null}
@@ -10,6 +10,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FilePenLine } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -26,6 +27,7 @@ import {
HistoryRow, HistoryRow,
HistoryRowIconButton, HistoryRowIconButton,
HistoryStatusPill, HistoryStatusPill,
HistoryTypeBadge,
RenameIcon, RenameIcon,
} from "./history-tab-shared"; } from "./history-tab-shared";
@@ -100,42 +102,45 @@ export function WritingHistoryTab({
return ( return (
<> <>
{filtered.map((session) => { <div>
const { label, color } = STATUS_LABELS[session.status] ?? { {filtered.map((session) => {
label: session.status, const { label, color } = STATUS_LABELS[session.status] ?? {
color: "text-muted-foreground", label: session.status,
}; color: "text-muted-foreground",
return ( };
<HistoryRow return (
key={session.id} <HistoryRow
title={desensitize(session.title)} key={session.id}
meta={ badge={<HistoryTypeBadge icon={FilePenLine} label="AI写作" />}
<> title={desensitize(session.title)}
<HistoryStatusPill label={label} className={color} /> meta={
<span className="text-muted-foreground/60">·</span> <>
<span>{formatRelative(session.updated_at)}</span> <HistoryStatusPill label={label} className={color} />
</> <span className="text-muted-foreground/60">·</span>
} <span>{formatRelative(session.updated_at)}</span>
onOpen={() => openSession(session)} </>
> }
<HistoryRowIconButton onOpen={() => openSession(session)}
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title);
}}
> >
<RenameIcon /> <HistoryRowIconButton
</HistoryRowIconButton> title="重命名"
<HistoryRowIconButton onClick={() => {
title="删除" setRenameId(session.id);
onClick={() => void handleDelete(session)} setRenameTitle(session.title);
> }}
<DeleteIcon /> >
</HistoryRowIconButton> <RenameIcon />
</HistoryRow> </HistoryRowIconButton>
); <HistoryRowIconButton
})} title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog <HistoryRenameDialog
open={renameId !== null} open={renameId !== null}
@@ -7,7 +7,7 @@
*/ */
import { useEffect, useState, type ReactNode } from "react"; 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 { Button } from "@/components/ui/button";
import { 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 后跳转 * 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转
* 对应工作台,由页面挂载时消费),操作按钮经 children 传入。 * 对应工作台,由页面挂载时消费),操作按钮经 children 传入。
*/ */
export function HistoryRow({ export function HistoryRow({
badge,
title, title,
meta, meta,
onOpen, onOpen,
children, children,
}: { }: {
badge?: ReactNode;
title: ReactNode; title: ReactNode;
meta: ReactNode; meta: ReactNode;
onOpen: () => void; onOpen: () => void;
children?: ReactNode; children?: ReactNode;
}) { }) {
return ( 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 <button
type="button" type="button"
onClick={onOpen} 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"> <div className="flex min-w-0 items-center gap-2">
<span className="truncate">{title}</span> {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>
<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} {meta}
</div> </div>
</button> </button>
{/* Row actions — kept out of the open button so clicks don't trigger it. */} {/* 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} {children}
</div> </div>
</div> </div>
@@ -20,6 +20,7 @@
*/ */
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { Select as TSelect } from "tdesign-react"; import { Select as TSelect } from "tdesign-react";
@@ -444,7 +445,12 @@ export default function DeepResearchWorkbench() {
} }
function DeepResearchWorkbenchInner() { 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 [mode, setMode] = useState<ResearchMode>("basic");
const [generateImages, setGenerateImages] = useState(false); const [generateImages, setGenerateImages] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS); const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);