diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 7def3260..46026eb8 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -7,7 +7,10 @@ import { FileText, MoreHorizontal, Pencil, + Pin, + PinOff, Share2, + Star, Trash2, } from "lucide-react"; import Link from "next/link"; @@ -54,9 +57,17 @@ import { useDeleteThread, useRenameThread, useThreads, + useToggleThreadFavorite, + useToggleThreadPin, } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; -import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { + isThreadFavorite, + isThreadPinned, + pathOfThread, + sortThreadsPinnedFirst, + titleOfThread, +} from "@/core/threads/utils"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { isIMEComposing } from "@/lib/ime"; @@ -82,6 +93,8 @@ export function RecentChatList() { const { data: threads = [] } = useThreads(); const { mutate: deleteThread } = useDeleteThread(); const { mutate: renameThread } = useRenameThread(); + const { toggle: togglePin } = useToggleThreadPin(); + const { toggle: toggleFavorite } = useToggleThreadFavorite(); // Rename dialog state const [renameDialogOpen, setRenameDialogOpen] = useState(false); @@ -160,11 +173,13 @@ export function RecentChatList() { [t], ); - const visibleThreads = threads.filter( - (thread) => - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && - thread.metadata?.thread_type !== "roundtable", + const visibleThreads = sortThreadsPinnedFirst( + threads.filter( + (thread) => + thread.metadata?.thread_type !== "scheduler" && + thread.metadata?.thread_type !== "bootstrap" && + thread.metadata?.thread_type !== "roundtable", + ), ); if (visibleThreads.length === 0) { @@ -195,6 +210,8 @@ export function RecentChatList() {
{visibleThreads.map((thread) => { const isActive = pathOfThread(thread) === pathname; + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); return ( - {desensitize(titleOfThread(thread, t.pages.newChat))} + {pinned && ( + + )} + + {desensitize(titleOfThread(thread, t.pages.newChat))} + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( @@ -243,6 +265,36 @@ export function RecentChatList() { {t.common.rename} + + togglePin(thread.thread_id, !pinned) + } + > + {pinned ? ( + + ) : ( + + )} + {pinned ? t.common.unpin : t.common.pin} + + + toggleFavorite(thread.thread_id, !favorited) + } + > + + + {favorited + ? t.common.unfavorite + : t.common.favorite} + + handleShare(thread)} > diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 01520e16..8e9c2475 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -25,6 +25,10 @@ export const enUS: Translations = { edit: "Edit", rename: "Rename", share: "Share", + pin: "Pin", + unpin: "Unpin", + favorite: "Favorite", + unfavorite: "Unfavorite", openInNewWindow: "Open in new window", close: "Close", more: "More", @@ -255,6 +259,14 @@ export const enUS: Translations = { // Chats chats: { searchChats: "Search chats", + pinnedSection: "Pinned", + allChats: "All", + favorites: "Favorites", + onlyFavorites: "Favorites only", + prevPage: "Previous", + nextPage: "Next", + noChats: "No conversations yet", + noFavorites: "No favorited conversations yet", }, // Conversation sharing diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index c3c374a3..c1e053f9 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -26,6 +26,10 @@ export interface Translations { edit: string; rename: string; share: string; + pin: string; + unpin: string; + favorite: string; + unfavorite: string; openInNewWindow: string; close: string; more: string; @@ -198,6 +202,14 @@ export interface Translations { // Chats chats: { searchChats: string; + pinnedSection: string; + allChats: string; + favorites: string; + onlyFavorites: string; + prevPage: string; + nextPage: string; + noChats: string; + noFavorites: string; }; // Conversation sharing diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 50ecf6fe..c0606781 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -25,6 +25,10 @@ export const zhCN: Translations = { edit: "编辑", rename: "重命名", share: "分享", + pin: "置顶", + unpin: "取消置顶", + favorite: "收藏", + unfavorite: "取消收藏", openInNewWindow: "在新窗口打开", close: "关闭", more: "更多", @@ -242,6 +246,14 @@ export const zhCN: Translations = { // Chats chats: { searchChats: "搜索对话", + pinnedSection: "置顶", + allChats: "全部", + favorites: "收藏", + onlyFavorites: "只看收藏", + prevPage: "上一页", + nextPage: "下一页", + noChats: "暂无对话", + noFavorites: "暂无收藏的对话", }, // Conversation sharing diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 983bbf3b..6a021e46 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -621,15 +621,15 @@ export function useThreadStream({ lockThreadRagMode(threadId); } - const mergedContext = { - ...context, - ...extraContext, - }; - - await thread.submit( - { - messages: [ - { + const mergedContext = { + ...context, + ...extraContext, + }; + + await thread.submit( + { + messages: [ + { type: "human", content: [ { @@ -647,33 +647,33 @@ export function useThreadStream({ }, ], }, - { - threadId: threadId, - streamSubgraphs: true, - streamResumable: true, - config: { - recursion_limit: 1000, - }, - context: { - ...mergedContext, - thinking_enabled: mergedContext.mode !== "flash", - is_plan_mode: - mergedContext.mode === "pro" || mergedContext.mode === "ultra", - subagent_enabled: mergedContext.mode === "ultra", - reasoning_effort: - mergedContext.reasoning_effort ?? - (mergedContext.mode === "ultra" - ? "high" - : mergedContext.mode === "pro" - ? "medium" - : mergedContext.mode === "thinking" - ? "low" - : undefined), - memory_injection_enabled: - mergedContext.memory_injection_enabled ?? true, - rag_mode_enabled: ragModeEnabled, - thread_id: threadId, - }, + { + threadId: threadId, + streamSubgraphs: true, + streamResumable: true, + config: { + recursion_limit: 1000, + }, + context: { + ...mergedContext, + thinking_enabled: mergedContext.mode !== "flash", + is_plan_mode: + mergedContext.mode === "pro" || mergedContext.mode === "ultra", + subagent_enabled: mergedContext.mode === "ultra", + reasoning_effort: + mergedContext.reasoning_effort ?? + (mergedContext.mode === "ultra" + ? "high" + : mergedContext.mode === "pro" + ? "medium" + : mergedContext.mode === "thinking" + ? "low" + : undefined), + memory_injection_enabled: + mergedContext.memory_injection_enabled ?? true, + rag_mode_enabled: ragModeEnabled, + thread_id: threadId, + }, }, ); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); @@ -853,6 +853,86 @@ export function useDeleteMessage() { }); } +/** + * Merge metadata into a conversation's record via the Gateway + * `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such + * as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a + * single user, so these live directly on the thread metadata (no join table). + * Updates the `["threads", "search"]` cache optimistically so the UI reflects + * the toggle immediately, mirroring `useRenameThread`. + */ +export function useUpdateThreadMetadata() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + threadId, + metadata, + }: { + threadId: string; + metadata: Record; + }) => { + const response = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ metadata }), + }, + ); + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "更新对话失败。" })); + throw new Error(error.detail ?? "更新对话失败。"); + } + }, + onSuccess(_, { threadId, metadata }) { + queryClient.setQueriesData( + { + queryKey: ["threads", "search"], + exact: false, + }, + (oldData: Array | undefined) => { + if (oldData == null) { + return oldData; + } + return oldData.map((t) => + t.thread_id === threadId + ? { + ...t, + metadata: { + ...(t.metadata as Record | null), + ...metadata, + }, + } + : t, + ); + }, + ); + }, + }); +} + +/** Toggle a conversation's pinned (置顶) flag. */ +export function useToggleThreadPin() { + const { mutate, ...rest } = useUpdateThreadMetadata(); + return { + ...rest, + toggle: (threadId: string, pinned: boolean) => + mutate({ threadId, metadata: { is_pinned: pinned } }), + }; +} + +/** Toggle a conversation's favorite (收藏) flag. */ +export function useToggleThreadFavorite() { + const { mutate, ...rest } = useUpdateThreadMetadata(); + return { + ...rest, + toggle: (threadId: string, favorite: boolean) => + mutate({ threadId, metadata: { is_favorite: favorite } }), + }; +} + export function useRenameThread() { const queryClient = useQueryClient(); const apiClient = getAPIClient(); diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 6148a239..7a3d8769 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -81,6 +81,28 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") { return fallback; } +export function isThreadPinned(thread: AgentThread): boolean { + return (thread.metadata as Record | null)?.is_pinned === true; +} + +export function isThreadFavorite(thread: AgentThread): boolean { + return (thread.metadata as Record | null)?.is_favorite === true; +} + +/** + * Stable sort that lifts pinned (置顶) threads to the top while preserving the + * incoming order (typically `updated_at` desc) within both the pinned and the + * unpinned group. + */ +export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] { + const pinned: AgentThread[] = []; + const rest: AgentThread[] = []; + for (const thread of threads) { + (isThreadPinned(thread) ? pinned : rest).push(thread); + } + return [...pinned, ...rest]; +} + export function sanitizeThreadTitleForDisplay(title: string | null | undefined) { if (!title) return "Untitled"; diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index d4ba237c..d10b3c52 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -1,95 +1,255 @@ -"use client"; - -import { DownloadIcon } 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 { useThreads } from "@/core/threads/hooks"; -import { usePathOfThread, titleOfThread } from "@/core/threads/utils"; -import { formatTimeAgo } from "@/core/utils/datetime"; -import { desensitize } from "@/lib/desensitize"; - -export default function ChatsPage() { - const pathOfThread = usePathOfThread(); - const { t } = useI18n(); - const { data: threads } = useThreads(); - const [search, setSearch] = useState(""); - const [importOpen, setImportOpen] = useState(false); - - useEffect(() => { - document.title = `${t.pages.chats} - ${t.pages.appName}`; - }, [t.pages.chats, t.pages.appName]); - - const filteredThreads = useMemo(() => { - return threads?.filter((thread) => { - return ( - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "roundtable" && - titleOfThread(thread).toLowerCase().includes(search.toLowerCase()) - ); - }); - }, [threads, search]); - return ( - - - -
-
-
- -
- setSearch(e.target.value)} - /> -
-
- -
- {filteredThreads?.map((thread) => ( - -
-
-
{desensitize(titleOfThread(thread, t.pages.newChat))}
-
- {thread.updated_at && ( -
- {formatTimeAgo(thread.updated_at)} -
- )} -
- - ))} -
-
-
-
-
- -
- ); -} +"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 { + useThreads, + 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 { data: threads } = useThreads(); + const { toggle: togglePin } = useToggleThreadPin(); + const { toggle: toggleFavorite } = useToggleThreadFavorite(); + const [search, setSearch] = 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]); + + // Reset to the first page whenever the filtered result set changes. + useEffect(() => { + setPage(1); + }, [search, onlyFavorites]); + + const filteredThreads = useMemo(() => { + return (threads ?? []).filter((thread) => { + if ( + thread.metadata?.thread_type === "scheduler" || + thread.metadata?.thread_type === "roundtable" + ) { + return false; + } + if (onlyFavorites && !isThreadFavorite(thread)) { + return false; + } + return titleOfThread(thread) + .toLowerCase() + .includes(search.toLowerCase()); + }); + }, [threads, search, onlyFavorites]); + + // Pinned conversations are surfaced in their own section and excluded from + // the paginated list so they stay visible regardless of the current page. + const pinnedThreads = useMemo( + () => filteredThreads.filter(isThreadPinned), + [filteredThreads], + ); + const restThreads = useMemo( + () => filteredThreads.filter((t) => !isThreadPinned(t)), + [filteredThreads], + ); + + const totalPages = Math.max(1, Math.ceil(restThreads.length / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const pagedThreads = restThreads.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE, + ); + + const renderThread = (thread: AgentThread) => { + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); + return ( +
+ +
+ {pinned && ( + + )} + + {desensitize(titleOfThread(thread, t.pages.newChat))} + +
+ {thread.updated_at && ( +
+ {formatTimeAgo(thread.updated_at)} +
+ )} + + {/* Row actions — kept out of the Link so clicks don't navigate. */} +
+ + +
+
+ ); + }; + + const isEmpty = filteredThreads.length === 0; + + return ( + + + +
+
+
+
+ + +
+ +
+ setSearch(e.target.value)} + /> +
+
+ +
+ {pinnedThreads.length > 0 && ( + <> +
+ {t.chats.pinnedSection} +
+ {pinnedThreads.map(renderThread)} + + )} + {pagedThreads.map(renderThread)} + {isEmpty && ( +
+ {onlyFavorites ? t.chats.noFavorites : t.chats.noChats} +
+ )} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+
+
+
+
+ +
+ ); +}