/** * dashboard-client/src/tabs/TopicsTab.tsx — Auto-categorizing wiki (S51 + S52 polish). * * Fetches /api/topics and renders the k-means + TF-IDF topic clusters. S52 polish * adds: (1) a search box filtering topics by label / discriminative term, and * (2) a topic drill-down — click a topic to load its member memories * (/api/topics/:id/memories). Read-only; no write operations. */ import type React from "react"; import { useCallback, useMemo, useState } from "react"; import { useApi } from "../hooks/useApi"; import { fetchTopics, fetchTopicMemories } from "../api/client"; import type { TopicsResponse, TopicMemoriesResponse } from "@contracts"; import { Badge } from "../components/ui/badge"; import { Card } from "../components/ui/card"; function fmtTs(ms: number | null): string { if (!ms) return "—"; return new Date(ms).toLocaleString(); } export default function TopicsTab(): React.ReactElement { const { data, loading, error } = useApi( useCallback(() => fetchTopics(), []), { pollInterval: 30_000 }, ); const [query, setQuery] = useState(""); const [drillTopicId, setDrillTopicId] = useState(null); const drill = useApi( useCallback( () => drillTopicId ? fetchTopicMemories(drillTopicId) : Promise.reject(), [drillTopicId], ), { pollInterval: 0 }, ); const filtered = useMemo(() => { if (!data) return []; const q = query.trim().toLowerCase(); if (!q) return data.topics; return data.topics.filter( (t) => t.label.toLowerCase().includes(q) || t.termScore.some((s) => s.term.toLowerCase().includes(q)), ); }, [data, query]); if (error && !data) { return (
Error loading topics: {error.message}
); } if (loading && !data) { return (
Loading topics…
); } if (!data) { return (
No topic data available.
); } if (data.totalTopics === 0) { return (

Wiki

No topics yet. Topics are auto-generated after every 3rd compaction from real memory embeddings (k-means + TF-IDF).

Check back after a few more compaction cycles.

); } return (

Wiki

{data.totalTopics} topic{data.totalTopics !== 1 ? "s" : ""} ·{" "} {data.totalAssigned} assigned memori {data.totalAssigned !== 1 ? "es" : "y"} {data.lastRebuildAt != null && ( <> · last rebuild {new Date(data.lastRebuildAt).toLocaleString()} )}

setQuery(e.target.value)} /> {drillTopicId && (

Topic {drillTopicId} {drill.data && <> · {drill.data.label}} {drill.data && <> · {drill.data.assignments.length} memories}

{drill.error ? (
Error: {(drill.error as Error).message}
) : drill.loading && !drill.data ? (
Loading memories…
) : drill.data ? ( {drill.data.assignments.map((a) => ( ))}
Memory id Confidence Assigned
{a.memoryId} {a.confidence != null ? a.confidence.toFixed(2) : "—"} {fmtTs(a.assignedAt)}
) : null}
)} {!drillTopicId && ( {filtered.map((t) => ( setDrillTopicId(t.id)} > ))} {filtered.length === 0 && ( )}
Label Memories Top Terms
{t.label} {t.memoryCount} {t.termScore .slice(0, 8) .map((s) => s.term) .join(", ")}
No topics match “{query}”.
)}
); }