"use client"; /** * SessionActivityPanel — right-side column of *floating cards* recording * the conversation's tools, knowledge bases, Space refs, and attachments. * * Design notes * ──────────── * • The panel itself has **no background** — cards float over the page so * the chat surface still bleeds through. Each card carries its own border * + faint shadow so it reads as a discrete block. * • Clicking an attachment row fires `onOpenAttachment(att)` upward; the * parent routes it into the SessionViewerPanel as a new file tab. * • Section content is suppressed entirely when empty — no skeleton cards * for tools/KBs/Space/attachments that never showed up in this session. */ import { useEffect, useState, type ReactNode } from "react"; import Link from "next/link"; import { AtSign, BookOpen, Brain, ClipboardList, Database, ExternalLink, History, NotebookPen, Paperclip, UserRound, Wrench, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { docIconFor, isSvgFilename } from "@/lib/doc-attachments"; import type { MessageAttachment, MessageItem, MessageRequestSnapshot, } from "@/context/UnifiedChatContext"; import type { StreamEvent } from "@/lib/unified-ws"; import { listSessions, type SessionSummary } from "@/lib/session-api"; import { listNotebooks, type NotebookSummary } from "@/lib/notebook-api"; import { bookApi } from "@/lib/book-api"; import type { Book } from "@/lib/book-types"; /* ------------------------------------------------------------------ */ /* Aggregator */ /* ------------------------------------------------------------------ */ export interface ToolUsage { name: string; count: number; } export interface AttachmentWithOrigin { messageIndex: number; attachment: MessageAttachment; } export interface SpaceReferenceSummary { historySessionIds: string[]; bookPageCount: number; bookIds: string[]; bookPages: Map; notebookRecordCount: number; notebookIds: string[]; questionEntryIds: number[]; personas: string[]; memoryKinds: Array<"summary" | "profile">; } export interface SessionActivity { tools: ToolUsage[]; knowledgeBases: string[]; space: SpaceReferenceSummary; attachments: AttachmentWithOrigin[]; isEmpty: boolean; } export function buildSessionActivity(messages: MessageItem[]): SessionActivity { const toolCounts = new Map(); const kbs = new Set(); const historySessionIds = new Set(); const bookIds = new Set(); const bookPages = new Map(); let bookPageCount = 0; const notebookIds = new Set(); let notebookRecordCount = 0; const questionEntryIds = new Set(); const personas = new Set(); const memoryKinds = new Set<"summary" | "profile">(); const attachments: AttachmentWithOrigin[] = []; messages.forEach((msg, idx) => { msg.events?.forEach((event: StreamEvent) => { if (event.type !== "tool_call") return; const name = String((event.metadata as { tool?: string } | undefined)?.tool || "") || event.content?.trim() || "tool"; toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1); }); msg.attachments?.forEach((a) => { attachments.push({ messageIndex: idx, attachment: a }); }); const snap: MessageRequestSnapshot | undefined = msg.requestSnapshot; if (snap) { snap.knowledgeBases?.forEach((k) => kbs.add(k)); snap.historyReferences?.forEach((s) => historySessionIds.add(s)); snap.bookReferences?.forEach((b) => { bookIds.add(b.book_id); bookPageCount += b.page_ids?.length ?? 0; const existing = bookPages.get(b.book_id) ?? []; bookPages.set(b.book_id, [...existing, ...(b.page_ids ?? [])]); }); snap.notebookReferences?.forEach((n) => { notebookIds.add(n.notebook_id); notebookRecordCount += n.record_ids?.length ?? 0; }); snap.questionNotebookReferences?.forEach((q) => questionEntryIds.add(q)); if (snap.persona) personas.add(snap.persona); snap.memoryReferences?.forEach((k) => memoryKinds.add(k)); } }); const tools = Array.from(toolCounts.entries()) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); const space: SpaceReferenceSummary = { historySessionIds: Array.from(historySessionIds), bookPageCount, bookIds: Array.from(bookIds), bookPages, notebookRecordCount, notebookIds: Array.from(notebookIds), questionEntryIds: Array.from(questionEntryIds), personas: Array.from(personas), memoryKinds: Array.from(memoryKinds), }; const isEmpty = tools.length === 0 && kbs.size === 0 && attachments.length === 0 && space.historySessionIds.length === 0 && space.bookIds.length === 0 && space.notebookIds.length === 0 && space.questionEntryIds.length === 0 && space.personas.length === 0 && space.memoryKinds.length === 0; return { tools, knowledgeBases: Array.from(kbs), space, attachments, isEmpty, }; } /* ------------------------------------------------------------------ */ /* Title resolver — lazy id -> title for Space items */ /* ------------------------------------------------------------------ */ interface ResolvedTitles { sessions: Map; notebooks: Map; books: Map; } function useResolvedTitles( activity: SessionActivity, open: boolean, ): ResolvedTitles { const [sessions, setSessions] = useState>(new Map()); const [notebooks, setNotebooks] = useState>(new Map()); const [books, setBooks] = useState>(new Map()); const needsSessions = activity.space.historySessionIds.length > 0; const needsNotebooks = activity.space.notebookIds.length > 0; const needsBooks = activity.space.bookIds.length > 0; useEffect(() => { if (!open || !needsSessions || sessions.size > 0) return; let cancelled = false; listSessions(200) .then((rows: SessionSummary[]) => { if (cancelled) return; const map = new Map(); rows.forEach((r) => map.set(r.session_id, r.title || r.session_id)); setSessions(map); }) .catch(() => {}); return () => { cancelled = true; }; }, [open, needsSessions, sessions.size]); useEffect(() => { if (!open || !needsNotebooks || notebooks.size > 0) return; let cancelled = false; listNotebooks() .then((rows: NotebookSummary[]) => { if (cancelled) return; const map = new Map(); rows.forEach((r) => map.set(r.id, r.name || r.id)); setNotebooks(map); }) .catch(() => {}); return () => { cancelled = true; }; }, [open, needsNotebooks, notebooks.size]); useEffect(() => { if (!open || !needsBooks || books.size > 0) return; let cancelled = false; bookApi .list() .then(({ books: rows }: { books: Book[] }) => { if (cancelled) return; const map = new Map(); rows.forEach((r) => map.set(r.id, r.title || r.id)); setBooks(map); }) .catch(() => {}); return () => { cancelled = true; }; }, [open, needsBooks, books.size]); return { sessions, notebooks, books }; } /* ------------------------------------------------------------------ */ /* Activity body */ /* */ /* Rendered as the "Activity" home view inside SessionViewerPanel (it */ /* used to live in its own floating-card panel; the two were merged */ /* so the session's activity is the viewer's landing and files open */ /* as tabs alongside it). */ /* ------------------------------------------------------------------ */ interface SpaceCategoryDef { key: string; href: string; label: string; icon: LucideIcon; } const SPACE_CATEGORIES: Record = { chat_history: { key: "chat_history", href: "/space/chat-history", label: "Chat history", icon: History, }, books: { key: "books", href: "/space/books", label: "Books", icon: BookOpen, }, notebooks: { key: "notebooks", href: "/space/notebooks", label: "Notebooks", icon: NotebookPen, }, question_bank: { key: "question_bank", href: "/space/questions", label: "Question bank", icon: ClipboardList, }, persona: { key: "persona", href: "/space/personas", label: "Persona", icon: UserRound, }, memory: { key: "memory", href: "/memory", label: "Memory", icon: Brain, }, }; export function ActivityBody({ activity, open, onOpenAttachment, configSection, }: { activity: SessionActivity; open: boolean; onOpenAttachment: (a: MessageAttachment) => void; configSection?: ReactNode; }) { const { t } = useTranslation(); const { tools, knowledgeBases, space, attachments } = activity; const { sessions, notebooks, books } = useResolvedTitles(activity, open); const spaceSubsections: ReactNode[] = []; if (space.historySessionIds.length > 0) { spaceSubsections.push( {space.historySessionIds.map((id) => ( ))} , ); } if (space.bookIds.length > 0) { spaceSubsections.push( {space.bookIds.map((id) => { const pages = space.bookPages.get(id)?.length ?? 0; return ( ); })} , ); } if (space.notebookIds.length > 0) { spaceSubsections.push( {space.notebookIds.map((id) => ( ))} , ); } if (space.questionEntryIds.length > 0) { spaceSubsections.push( {space.questionEntryIds.map((id) => ( ))} , ); } if (space.personas.length > 0) { spaceSubsections.push( {space.personas.map((persona) => ( ))} , ); } if (space.memoryKinds.length > 0) { spaceSubsections.push( {space.memoryKinds.map((kind) => ( ))} , ); } if (activity.isEmpty && !configSection) { return (
{t( "As you chat, the tools, references and attachments you use will appear here.", )}
); } return (
{tools.length > 0 ? (
    {tools.map((tool) => (
  • {tool.name} ×{tool.count}
  • ))}
) : null} {knowledgeBases.length > 0 ? (
    {knowledgeBases.map((kb) => (
  • {kb}
  • ))}
) : null} {spaceSubsections.length > 0 ? (
{spaceSubsections}
) : null} {attachments.length > 0 ? (
    {attachments.map(({ attachment, messageIndex }, i) => ( onOpenAttachment(attachment)} /> ))}
) : null} {configSection}
); } /* ------------------------------------------------------------------ */ /* Card primitives */ /* ------------------------------------------------------------------ */ function SectionCard({ icon: Icon, title, count, children, }: { icon: LucideIcon; title: string; count?: number; children: ReactNode; }) { return (
{title} {count !== undefined && count > 0 ? ( {count} ) : null}
{children}
); } function SpaceSubsection({ category, count, children, }: { category: SpaceCategoryDef; count: number; children: ReactNode; }) { const Icon = category.icon; return (
{category.label} {count}
    {children}
); } function SpaceItemRow({ title, subtitle, }: { title: string; subtitle?: string; }) { return (
  • {title} {subtitle ? ( {subtitle} ) : null}
  • ); } function AttachmentRow({ attachment, onOpen, }: { attachment: MessageAttachment; onOpen: () => void; }) { const filename = attachment.filename || "untitled"; const spec = docIconFor(filename); const Icon = spec.Icon; const isImage = attachment.type === "image" || isSvgFilename(filename); return (
  • ); }