diff --git a/packages/studio/src/components/Sidebar.tsx b/packages/studio/src/components/Sidebar.tsx index 1eae1a71..245428bd 100644 --- a/packages/studio/src/components/Sidebar.tsx +++ b/packages/studio/src/components/Sidebar.tsx @@ -1,8 +1,25 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useApi } from "../hooks/use-api"; import type { SSEMessage } from "../hooks/use-sse"; import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity"; import type { TFunction } from "../hooks/use-i18n"; +import { useChatStore } from "../store/chat"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; import { Book, Settings, @@ -15,6 +32,11 @@ import { FileInput, TrendingUp, Stethoscope, + ChevronRight, + Loader2, + MoreHorizontal, + Pencil, + Trash2, } from "lucide-react"; interface BookSummary { @@ -47,6 +69,22 @@ export function Sidebar({ nav, activePage, sse, t }: { }) { const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray }>("/books"); const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon"); + const sessions = useChatStore((s) => s.sessions); + const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook); + const activeSessionId = useChatStore((s) => s.activeSessionId); + const bookDataVersion = useChatStore((s) => s.bookDataVersion); + const loadSessionList = useChatStore((s) => s.loadSessionList); + const loadSessionDetail = useChatStore((s) => s.loadSessionDetail); + const activateSession = useChatStore((s) => s.activateSession); + const createSession = useChatStore((s) => s.createSession); + const renameSession = useChatStore((s) => s.renameSession); + const deleteSession = useChatStore((s) => s.deleteSession); + const [expandedBooks, setExpandedBooks] = useState>({}); + const [renameTarget, setRenameTarget] = useState<{ sessionId: string; currentTitle: string } | null>(null); + const [renameValue, setRenameValue] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ sessionId: string; title: string } | null>(null); + + const books = data?.books ?? []; useEffect(() => { const recent = sse.messages.at(-1); @@ -59,6 +97,59 @@ export function Sidebar({ nav, activePage, sse, t }: { } }, [refetchBooks, refetchDaemon, sse.messages]); + useEffect(() => { + for (const book of books) { + void loadSessionList(book.id); + } + }, [bookDataVersion, books, loadSessionList]); + + useEffect(() => { + if (!activePage.startsWith("book:")) return; + const activeBookId = activePage.slice("book:".length); + setExpandedBooks((prev) => ({ ...prev, [activeBookId]: true })); + }, [activePage]); + + const sessionsByBook = useMemo( + () => + Object.fromEntries( + books.map((book) => [ + book.id, + (sessionIdsByBook[book.id] ?? []) + .map((sessionId) => sessions[sessionId]) + .filter(Boolean), + ]), + ) as Record>, + [books, sessionIdsByBook, sessions], + ); + + const openSession = (bookId: string, sessionId: string) => { + activateSession(sessionId); + nav.toBook(bookId); + void loadSessionDetail(sessionId); + }; + + const handleCreateSession = async (bookId: string) => { + const sessionId = await createSession(bookId); + setExpandedBooks((prev) => ({ ...prev, [bookId]: true })); + nav.toBook(bookId); + await loadSessionDetail(sessionId); + }; + + const handleRenameConfirm = async () => { + if (!renameTarget) return; + const nextTitle = renameValue.trim(); + if (!nextTitle) return; + await renameSession(renameTarget.sessionId, nextTitle); + setRenameTarget(null); + setRenameValue(""); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + await deleteSession(deleteTarget.sessionId); + setDeleteTarget(null); + }; + return ( ); } +function getSessionLabel(sessionId: string, title: string | null): string { + if (title) return title; + const rawTs = Number(sessionId.split("-")[0]); + if (!Number.isFinite(rawTs)) return "新会话"; + const formatted = new Date(rawTs).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + return `新会话 · ${formatted}`; +} + function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: { label: string; icon: React.ReactNode;