* feat: observation_scopes field to drive observations granularity * fix(migration): make a2b3c4d5e6f7 a no-op to fix CI on fresh DB The z1u2v3w4x5y6 migration already creates observation_scopes directly, so the rename migration fails on fresh installs where observation_tags never existed. * chore: remove no-op migration a2b3c4d5e6f7 * feat: regenerate clients with observation_scopes field - Add observation_scopes to OpenAPI spec and all generated clients - Fix Rust build.rs to handle anyOf with >2 variants containing null (previously only handled 2-item anyOf, causing progenitor to panic on the observation_scopes union type) * fix(rust): add observation_scopes: None to MemoryItem struct literals * fix(api): add title to observation_scopes Field for deterministic client generation Adding title="ObservationScopes" makes the inline anyOf schema use the explicit name instead of deriving it from the field name, which was non-deterministic between arm64 (macOS) and amd64 (CI) Docker. Also fixes description: "each entity" -> "each tag". * fix(scripts): use linux/amd64 Docker for client generation to ensure reproducibility Both Python and Go client generation now use --platform linux/amd64 Docker, ensuring identical output on macOS arm64 (local) and Linux amd64 (CI). Also switches Go from JAR+Java to Docker to eliminate Java version variability. * chore: update generated clients to API v0.4.14 * fix(test): add retry logic to test_retain_chinese_content to handle non-deterministic LLM output * fix(test): mark test_retain_chinese_content as xfail due to non-deterministic LLM translation
1269 lines
53 KiB
TypeScript
1269 lines
53 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||
import { client } from "@/lib/api";
|
||
import { useBank } from "@/lib/bank-context";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Calendar,
|
||
ZoomIn,
|
||
ZoomOut,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
Settings2,
|
||
Eye,
|
||
EyeOff,
|
||
RefreshCw,
|
||
CheckCircle,
|
||
Clock,
|
||
Network,
|
||
List,
|
||
Search,
|
||
Tag,
|
||
X,
|
||
} from "lucide-react";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { MemoryDetailPanel } from "./memory-detail-panel";
|
||
import { MemoryDetailModal } from "./memory-detail-modal";
|
||
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
|
||
|
||
type FactType = "world" | "experience" | "observation";
|
||
type ViewMode = "graph" | "table" | "timeline";
|
||
|
||
interface DataViewProps {
|
||
factType: FactType;
|
||
}
|
||
|
||
export function DataView({ factType }: DataViewProps) {
|
||
const { currentBank } = useBank();
|
||
const [viewMode, setViewMode] = useState<ViewMode>("graph");
|
||
const [data, setData] = useState<any>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [tagFilters, setTagFilters] = useState<string[]>([]);
|
||
const [tagInput, setTagInput] = useState("");
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
|
||
const [modalMemoryId, setModalMemoryId] = useState<string | null>(null);
|
||
const itemsPerPage = 100;
|
||
|
||
// Fetch limit state - how many memories to load from the API
|
||
const [fetchLimit, setFetchLimit] = useState(1000);
|
||
|
||
// Consolidation status for mental models
|
||
const [consolidationStatus, setConsolidationStatus] = useState<{
|
||
pending_consolidation: number;
|
||
last_consolidated_at: string | null;
|
||
} | null>(null);
|
||
|
||
// Graph controls state
|
||
const [showLabels, setShowLabels] = useState(true);
|
||
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
|
||
const [showControlPanel, setShowControlPanel] = useState(true);
|
||
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
|
||
new Set(["semantic", "temporal", "entity", "causal"])
|
||
);
|
||
|
||
const toggleLinkType = (type: string) => {
|
||
setVisibleLinkTypes((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(type)) {
|
||
next.delete(type);
|
||
} else {
|
||
next.add(type);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Esc key handler to deselect graph node
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape" && selectedGraphNode) {
|
||
setSelectedGraphNode(null);
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [selectedGraphNode]);
|
||
|
||
const loadData = async (limit?: number, q?: string, tags?: string[]) => {
|
||
if (!currentBank) return;
|
||
|
||
setLoading(true);
|
||
try {
|
||
const graphData: any = await client.getGraph({
|
||
bank_id: currentBank,
|
||
type: factType,
|
||
limit: limit ?? fetchLimit,
|
||
q,
|
||
tags,
|
||
});
|
||
setData(graphData);
|
||
|
||
// Fetch consolidation status for observations
|
||
if (factType === "observation") {
|
||
const stats: any = await client.getBankStats(currentBank);
|
||
setConsolidationStatus({
|
||
pending_consolidation: stats.pending_consolidation || 0,
|
||
last_consolidated_at: stats.last_consolidated_at || null,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
// Error toast is shown automatically by the API client interceptor
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const addTagFilter = (tag: string) => {
|
||
const trimmed = tag.trim();
|
||
if (trimmed && !tagFilters.includes(trimmed)) {
|
||
setTagFilters((prev) => [...prev, trimmed]);
|
||
}
|
||
setTagInput("");
|
||
};
|
||
|
||
const removeTagFilter = (tag: string) => {
|
||
setTagFilters((prev) => prev.filter((t) => t !== tag));
|
||
};
|
||
|
||
// Table rows are already filtered server-side
|
||
const filteredTableRows = useMemo(() => {
|
||
return data?.table_rows ?? [];
|
||
}, [data]);
|
||
|
||
// Helper to get normalized link type
|
||
const getLinkTypeCategory = (type: string | undefined): string => {
|
||
if (!type) return "semantic";
|
||
if (type === "semantic" || type === "temporal" || type === "entity") return type;
|
||
if (["causes", "caused_by", "enables", "prevents"].includes(type)) return "causal";
|
||
return "semantic";
|
||
};
|
||
|
||
// Convert data for Graph2D (graph data is already filtered server-side)
|
||
const graph2DData = useMemo(() => {
|
||
if (!data) return { nodes: [], links: [] };
|
||
const fullData = convertHindsightGraphData(data);
|
||
|
||
// Filter links based on visible link types
|
||
const links = fullData.links.filter((link) => {
|
||
const category = getLinkTypeCategory(link.type);
|
||
return visibleLinkTypes.has(category);
|
||
});
|
||
|
||
return { nodes: fullData.nodes, links };
|
||
}, [data, visibleLinkTypes]);
|
||
|
||
// Calculate link stats for display
|
||
const linkStats = useMemo(() => {
|
||
let semantic = 0,
|
||
temporal = 0,
|
||
entity = 0,
|
||
causal = 0,
|
||
total = 0;
|
||
const otherTypes: Record<string, number> = {};
|
||
graph2DData.links.forEach((l) => {
|
||
total++;
|
||
const type = l.type || "unknown";
|
||
if (type === "semantic") semantic++;
|
||
else if (type === "temporal") temporal++;
|
||
else if (type === "entity") entity++;
|
||
else if (
|
||
type === "causes" ||
|
||
type === "caused_by" ||
|
||
type === "enables" ||
|
||
type === "prevents"
|
||
)
|
||
causal++;
|
||
else {
|
||
otherTypes[type] = (otherTypes[type] || 0) + 1;
|
||
}
|
||
});
|
||
return { semantic, temporal, entity, causal, total, otherTypes };
|
||
}, [graph2DData]);
|
||
|
||
// Handle node click in graph - show in panel
|
||
const handleGraphNodeClick = useCallback(
|
||
(node: GraphNode) => {
|
||
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
|
||
if (nodeData) {
|
||
setSelectedGraphNode(nodeData);
|
||
}
|
||
},
|
||
[data]
|
||
);
|
||
|
||
// Memoized color functions to prevent graph re-initialization
|
||
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
|
||
const nodeColorFn = useCallback((node: GraphNode) => node.color || "#0074d9", []);
|
||
const linkColorFn = useCallback((link: any) => {
|
||
if (link.type === "temporal") return "#009296"; // Brand teal
|
||
if (link.type === "entity") return "#f59e0b"; // Amber
|
||
if (
|
||
link.type === "causes" ||
|
||
link.type === "caused_by" ||
|
||
link.type === "enables" ||
|
||
link.type === "prevents"
|
||
) {
|
||
return "#8b5cf6"; // Purple for causal
|
||
}
|
||
return "#0074d9"; // Brand primary blue for semantic
|
||
}, []);
|
||
|
||
// Reset to first page when filters change
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchQuery, tagFilters]);
|
||
|
||
// Debounce ref for text search
|
||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
// Trigger server-side reload when text filter changes (debounced 300ms)
|
||
useEffect(() => {
|
||
if (searchDebounceRef.current) {
|
||
clearTimeout(searchDebounceRef.current);
|
||
}
|
||
searchDebounceRef.current = setTimeout(() => {
|
||
if (currentBank) {
|
||
loadData(
|
||
undefined,
|
||
searchQuery || undefined,
|
||
tagFilters.length > 0 ? tagFilters : undefined
|
||
);
|
||
}
|
||
}, 300);
|
||
return () => {
|
||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||
};
|
||
}, [searchQuery]);
|
||
|
||
// Trigger server-side reload immediately when tag filters change
|
||
useEffect(() => {
|
||
if (currentBank) {
|
||
loadData(undefined, searchQuery || undefined, tagFilters.length > 0 ? tagFilters : undefined);
|
||
}
|
||
}, [tagFilters]);
|
||
|
||
// Auto-load data when component mounts or factType/currentBank changes
|
||
useEffect(() => {
|
||
if (currentBank) {
|
||
loadData();
|
||
}
|
||
}, [factType, currentBank]);
|
||
|
||
// Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller
|
||
useEffect(() => {
|
||
if (data && maxNodes === undefined) {
|
||
if (graph2DData.nodes.length > 50) {
|
||
// Always set maxNodes to 20 when we have >50 nodes (never leave as undefined)
|
||
setMaxNodes(20);
|
||
} else if (graph2DData.nodes.length > 20) {
|
||
setMaxNodes(20);
|
||
}
|
||
// If ≤20 nodes, leave maxNodes undefined to show all
|
||
}
|
||
}, [data, graph2DData.nodes.length, maxNodes]);
|
||
|
||
return (
|
||
<div>
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||
<p className="text-muted-foreground">Loading memories...</p>
|
||
</div>
|
||
) : data ? (
|
||
<>
|
||
{/* Always visible filters */}
|
||
<div className="mb-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
{/* Text search */}
|
||
<div className="relative max-w-xs flex-1">
|
||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||
<Input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Filter by text or context..."
|
||
className="pl-8 h-9"
|
||
/>
|
||
</div>
|
||
{/* Tag input */}
|
||
<div className="relative max-w-xs flex-1">
|
||
<Tag className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||
<Input
|
||
type="text"
|
||
value={tagInput}
|
||
onChange={(e) => setTagInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === ",") {
|
||
e.preventDefault();
|
||
addTagFilter(tagInput);
|
||
} else if (e.key === "Backspace" && !tagInput && tagFilters.length > 0) {
|
||
removeTagFilter(tagFilters[tagFilters.length - 1]);
|
||
}
|
||
}}
|
||
placeholder="Filter by tag…"
|
||
className="pl-8 h-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/* Active tag chips */}
|
||
{tagFilters.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{tagFilters.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 font-medium leading-none"
|
||
>
|
||
<span className="opacity-50 select-none font-mono">#</span>
|
||
{tag}
|
||
<button
|
||
onClick={() => removeTagFilter(tag)}
|
||
className="opacity-50 hover:opacity-100 transition-opacity ml-0.5"
|
||
aria-label={`Remove tag ${tag}`}
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-4">
|
||
<div className="text-sm text-muted-foreground">
|
||
{searchQuery || tagFilters.length > 0 ? (
|
||
`${filteredTableRows.length} matching memories`
|
||
) : data.table_rows?.length < data.total_units ? (
|
||
<span>
|
||
Showing {data.table_rows?.length ?? 0} of {data.total_units} total memories
|
||
<button
|
||
onClick={() => {
|
||
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
|
||
setFetchLimit(newLimit);
|
||
loadData(newLimit);
|
||
}}
|
||
className="ml-2 text-primary hover:underline"
|
||
>
|
||
Load more
|
||
</button>
|
||
</span>
|
||
) : (
|
||
`${data.total_units} total memories`
|
||
)}
|
||
</div>
|
||
|
||
{/* Consolidation status for observations */}
|
||
{factType === "observation" && consolidationStatus && (
|
||
<div
|
||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||
consolidationStatus.pending_consolidation === 0
|
||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
|
||
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"
|
||
}`}
|
||
title={
|
||
consolidationStatus.pending_consolidation === 0
|
||
? `All memories consolidated${consolidationStatus.last_consolidated_at ? ` (last: ${new Date(consolidationStatus.last_consolidated_at).toLocaleString()})` : ""}`
|
||
: `${consolidationStatus.pending_consolidation} memories pending consolidation`
|
||
}
|
||
>
|
||
{consolidationStatus.pending_consolidation === 0 ? (
|
||
<>
|
||
<CheckCircle className="w-3 h-3" />
|
||
In Sync
|
||
</>
|
||
) : (
|
||
<>
|
||
<Clock className="w-3 h-3" />
|
||
{consolidationStatus.pending_consolidation} Pending
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
|
||
<button
|
||
onClick={() => setViewMode("graph")}
|
||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||
viewMode === "graph"
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
<Network className="w-4 h-4" />
|
||
Graph
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode("table")}
|
||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||
viewMode === "table"
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
<List className="w-4 h-4" />
|
||
Table
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode("timeline")}
|
||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||
viewMode === "timeline"
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
<Calendar className="w-4 h-4" />
|
||
Timeline
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{viewMode === "graph" && (
|
||
<div className="flex gap-0">
|
||
{/* Graph */}
|
||
<div className="flex-1 min-w-0">
|
||
<Graph2D
|
||
data={graph2DData}
|
||
height={700}
|
||
showLabels={showLabels}
|
||
onNodeClick={handleGraphNodeClick}
|
||
maxNodes={maxNodes}
|
||
nodeColorFn={nodeColorFn}
|
||
linkColorFn={linkColorFn}
|
||
/>
|
||
</div>
|
||
|
||
{/* Right Toggle Button */}
|
||
<button
|
||
onClick={() => setShowControlPanel(!showControlPanel)}
|
||
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
|
||
title={showControlPanel ? "Hide panel" : "Show panel"}
|
||
>
|
||
{showControlPanel ? (
|
||
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
|
||
) : (
|
||
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
|
||
)}
|
||
</button>
|
||
|
||
{/* Right Panel - Legend/Controls OR Memory Details */}
|
||
<div
|
||
className={`${showControlPanel ? "w-80" : "w-0"} transition-all duration-300 overflow-hidden flex-shrink-0`}
|
||
>
|
||
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
|
||
{selectedGraphNode ? (
|
||
/* Memory Detail View */
|
||
<MemoryDetailPanel
|
||
memory={selectedGraphNode}
|
||
onClose={() => setSelectedGraphNode(null)}
|
||
inPanel
|
||
bankId={currentBank || undefined}
|
||
/>
|
||
) : (
|
||
/* Legend & Controls View */
|
||
<div className="p-4 space-y-5">
|
||
{/* Legend & Stats */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold mb-3 text-foreground">Graph</h3>
|
||
<div className="space-y-2">
|
||
{/* Nodes */}
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className="w-3 h-3 rounded-full"
|
||
style={{ backgroundColor: "#0074d9" }}
|
||
/>
|
||
<span className="text-foreground">Nodes</span>
|
||
</div>
|
||
<span className="font-mono text-foreground">
|
||
{Math.min(
|
||
maxNodes ?? graph2DData.nodes.length,
|
||
graph2DData.nodes.length
|
||
)}
|
||
/{graph2DData.nodes.length}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">
|
||
Links ({linkStats.total}){" "}
|
||
<span className="text-muted-foreground/60">· click to filter</span>
|
||
</div>
|
||
<button
|
||
onClick={() => toggleLinkType("semantic")}
|
||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||
visibleLinkTypes.has("semantic")
|
||
? "hover:bg-muted"
|
||
: "opacity-40 hover:opacity-60"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-0.5 bg-[#0074d9]" />
|
||
<span className="text-foreground">Semantic</span>
|
||
</div>
|
||
<span
|
||
className={`font-mono ${linkStats.semantic === 0 ? "text-destructive" : "text-foreground"}`}
|
||
>
|
||
{linkStats.semantic}
|
||
</span>
|
||
</button>
|
||
<button
|
||
onClick={() => toggleLinkType("temporal")}
|
||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||
visibleLinkTypes.has("temporal")
|
||
? "hover:bg-muted"
|
||
: "opacity-40 hover:opacity-60"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-0.5 bg-[#009296]" />
|
||
<span className="text-foreground">Temporal</span>
|
||
</div>
|
||
<span
|
||
className={`font-mono ${linkStats.temporal === 0 ? "text-destructive" : "text-foreground"}`}
|
||
>
|
||
{linkStats.temporal}
|
||
</span>
|
||
</button>
|
||
<button
|
||
onClick={() => toggleLinkType("entity")}
|
||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||
visibleLinkTypes.has("entity")
|
||
? "hover:bg-muted"
|
||
: "opacity-40 hover:opacity-60"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-0.5 bg-[#f59e0b]" />
|
||
<span className="text-foreground">Entity</span>
|
||
</div>
|
||
<span className="font-mono text-foreground">{linkStats.entity}</span>
|
||
</button>
|
||
<button
|
||
onClick={() => toggleLinkType("causal")}
|
||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||
visibleLinkTypes.has("causal")
|
||
? "hover:bg-muted"
|
||
: "opacity-40 hover:opacity-60"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
|
||
<span className="text-foreground">Causal</span>
|
||
</div>
|
||
<span
|
||
className={`font-mono ${linkStats.causal === 0 ? "text-muted-foreground" : "text-foreground"}`}
|
||
>
|
||
{linkStats.causal}
|
||
</span>
|
||
</button>
|
||
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
|
||
<div key={type} className="flex items-center justify-between text-sm">
|
||
<span className="text-muted-foreground capitalize ml-6">{type}</span>
|
||
<span className="font-mono text-muted-foreground">
|
||
{count as number}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border" />
|
||
|
||
{/* Controls Section */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold mb-3 text-foreground">Display</h3>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="show-labels" className="text-sm text-foreground">
|
||
Show labels
|
||
</Label>
|
||
<Switch
|
||
id="show-labels"
|
||
checked={showLabels}
|
||
onCheckedChange={setShowLabels}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border" />
|
||
|
||
{/* Limits Section */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold mb-3 text-foreground">Performance</h3>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label className="text-sm text-foreground">Max nodes</Label>
|
||
<span className="text-xs text-muted-foreground">
|
||
{graph2DData.nodes.length > 50
|
||
? `${maxNodes ?? 50} / ${graph2DData.nodes.length}`
|
||
: `${maxNodes ?? "All"} / ${graph2DData.nodes.length}`}
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
value={[
|
||
graph2DData.nodes.length > 50
|
||
? maxNodes || 20
|
||
: maxNodes || Math.min(graph2DData.nodes.length, 20),
|
||
]}
|
||
min={10}
|
||
max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
|
||
step={10}
|
||
onValueChange={([v]) => {
|
||
const effectiveMax = Math.min(graph2DData.nodes.length, 50);
|
||
// If we have >50 nodes, never allow "All" (undefined), cap at 50
|
||
if (graph2DData.nodes.length > 50) {
|
||
setMaxNodes(v);
|
||
} else {
|
||
// Original behavior for ≤50 nodes: allow "All" when slider reaches max
|
||
setMaxNodes(v >= effectiveMax ? undefined : v);
|
||
}
|
||
}}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
All links between visible nodes are shown.
|
||
{graph2DData.nodes.length > 50 && (
|
||
<span className="block text-amber-600 dark:text-amber-400 mt-1">
|
||
⚠️ Limited to 50 nodes for performance. Total:{" "}
|
||
{graph2DData.nodes.length}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border" />
|
||
|
||
{/* Hint */}
|
||
<div className="text-xs text-muted-foreground/60 text-center pt-2">
|
||
Click a node to see details
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{viewMode === "table" && (
|
||
<div>
|
||
<div className="w-full">
|
||
<div className="pb-4">
|
||
{filteredTableRows.length > 0 ? (
|
||
(() => {
|
||
const totalPages = Math.ceil(filteredTableRows.length / itemsPerPage);
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
const endIndex = startIndex + itemsPerPage;
|
||
const paginatedRows = filteredTableRows.slice(startIndex, endIndex);
|
||
|
||
return (
|
||
<>
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<Table className="table-fixed">
|
||
<TableHeader>
|
||
<TableRow className="bg-muted/50">
|
||
<TableHead
|
||
className={factType === "observation" ? "w-[35%]" : "w-[38%]"}
|
||
>
|
||
{factType === "observation" ? "Observation" : "Memory"}
|
||
</TableHead>
|
||
<TableHead className="w-[15%]">Entities</TableHead>
|
||
<TableHead className="w-[15%]">Tags</TableHead>
|
||
{factType === "observation" && (
|
||
<TableHead className="w-[10%]">Sources</TableHead>
|
||
)}
|
||
<TableHead
|
||
className={factType === "observation" ? "w-[12%]" : "w-[16%]"}
|
||
>
|
||
Occurred
|
||
</TableHead>
|
||
<TableHead
|
||
className={factType === "observation" ? "w-[13%]" : "w-[16%]"}
|
||
>
|
||
Mentioned
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{paginatedRows.map((row: any, idx: number) => {
|
||
const occurredDisplay = row.occurred_start
|
||
? new Date(row.occurred_start).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
})
|
||
: null;
|
||
const mentionedDisplay = row.mentioned_at
|
||
? new Date(row.mentioned_at).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
})
|
||
: null;
|
||
|
||
return (
|
||
<TableRow
|
||
key={row.id || idx}
|
||
onClick={() => setModalMemoryId(row.id)}
|
||
className="cursor-pointer hover:bg-muted/50"
|
||
>
|
||
<TableCell className="py-2">
|
||
<div className="line-clamp-2 text-sm leading-snug text-foreground">
|
||
{row.text}
|
||
</div>
|
||
{row.context && factType !== "observation" && (
|
||
<div className="text-xs text-muted-foreground mt-0.5 truncate">
|
||
{row.context}
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="py-2">
|
||
{row.entities ? (
|
||
<div className="flex gap-1 flex-wrap">
|
||
{row.entities
|
||
.split(", ")
|
||
.slice(0, 2)
|
||
.map((entity: string, i: number) => (
|
||
<span
|
||
key={i}
|
||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium"
|
||
>
|
||
{entity}
|
||
</span>
|
||
))}
|
||
{row.entities.split(", ").length > 2 && (
|
||
<span className="text-[10px] text-muted-foreground">
|
||
+{row.entities.split(", ").length - 2}
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">-</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="py-2">
|
||
{row.tags && row.tags.length > 0 ? (
|
||
<div className="flex gap-1 flex-wrap">
|
||
{(row.tags as string[])
|
||
.slice(0, 2)
|
||
.map((tag: string, i: number) => (
|
||
<span
|
||
key={i}
|
||
className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-700 border border-amber-500/20 font-medium font-mono"
|
||
>
|
||
#{tag}
|
||
</span>
|
||
))}
|
||
{row.tags.length > 2 && (
|
||
<span className="text-[10px] text-muted-foreground">
|
||
+{row.tags.length - 2}
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">-</span>
|
||
)}
|
||
</TableCell>
|
||
{factType === "observation" && (
|
||
<TableCell className="text-xs py-2 text-foreground">
|
||
{row.proof_count ?? 1}
|
||
</TableCell>
|
||
)}
|
||
<TableCell className="text-xs py-2 text-foreground">
|
||
{occurredDisplay || (
|
||
<span className="text-muted-foreground">-</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-xs py-2 text-foreground">
|
||
{mentionedDisplay || (
|
||
<span className="text-muted-foreground">-</span>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* Pagination Controls */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
||
<div className="text-xs text-muted-foreground">
|
||
{startIndex + 1}-{Math.min(endIndex, filteredTableRows.length)} of{" "}
|
||
{filteredTableRows.length}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setCurrentPage(1)}
|
||
disabled={currentPage === 1}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<ChevronsLeft className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||
disabled={currentPage === 1}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<ChevronLeft className="h-3 w-3" />
|
||
</Button>
|
||
<span className="text-xs px-2">
|
||
{currentPage} / {totalPages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<ChevronRight className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setCurrentPage(totalPages)}
|
||
disabled={currentPage === totalPages}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<ChevronsRight className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
})()
|
||
) : (
|
||
<div className="text-center py-12 text-muted-foreground">
|
||
{data.table_rows?.length > 0
|
||
? "No memories match your filter"
|
||
: "No memories found"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{viewMode === "timeline" && (
|
||
<TimelineView
|
||
data={data}
|
||
filteredRows={filteredTableRows}
|
||
bankId={currentBank || undefined}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="flex items-center justify-center py-20">
|
||
<div className="text-center">
|
||
<div className="text-4xl mb-2">📊</div>
|
||
<div className="text-sm text-muted-foreground">No data available</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Memory Detail Modal */}
|
||
<MemoryDetailModal memoryId={modalMemoryId} onClose={() => setModalMemoryId(null)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Timeline View Component - Custom compact timeline with zoom and navigation
|
||
type Granularity = "year" | "month" | "week" | "day";
|
||
|
||
function TimelineView({
|
||
data,
|
||
filteredRows,
|
||
bankId,
|
||
}: {
|
||
data: any;
|
||
filteredRows: any[];
|
||
bankId?: string;
|
||
}) {
|
||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||
const [granularity, setGranularity] = useState<Granularity>("month");
|
||
const [currentIndex, setCurrentIndex] = useState(0);
|
||
const timelineRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Filter and sort items that have occurred_start dates (using filtered data)
|
||
const { sortedItems, itemsWithoutDates } = useMemo(() => {
|
||
if (!filteredRows || filteredRows.length === 0)
|
||
return { sortedItems: [], itemsWithoutDates: [] };
|
||
|
||
const withDates = filteredRows
|
||
.filter((row: any) => row.occurred_start)
|
||
.sort((a: any, b: any) => {
|
||
const dateA = new Date(a.occurred_start).getTime();
|
||
const dateB = new Date(b.occurred_start).getTime();
|
||
return dateA - dateB;
|
||
});
|
||
|
||
const withoutDates = filteredRows.filter((row: any) => !row.occurred_start);
|
||
|
||
return { sortedItems: withDates, itemsWithoutDates: withoutDates };
|
||
}, [filteredRows]);
|
||
|
||
// Group items by granularity
|
||
const timelineGroups = useMemo(() => {
|
||
if (sortedItems.length === 0) return [];
|
||
|
||
const getGroupKey = (date: Date): string => {
|
||
const year = date.getFullYear();
|
||
const month = date.getMonth();
|
||
const day = date.getDate();
|
||
|
||
switch (granularity) {
|
||
case "year":
|
||
return `${year}`;
|
||
case "month":
|
||
return `${year}-${String(month + 1).padStart(2, "0")}`;
|
||
case "week":
|
||
const startOfWeek = new Date(date);
|
||
startOfWeek.setDate(day - date.getDay());
|
||
return `${startOfWeek.getFullYear()}-W${String(Math.ceil(startOfWeek.getDate() / 7)).padStart(2, "0")}-${String(startOfWeek.getMonth() + 1).padStart(2, "0")}-${String(startOfWeek.getDate()).padStart(2, "0")}`;
|
||
case "day":
|
||
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||
}
|
||
};
|
||
|
||
const getGroupLabel = (key: string, date: Date): string => {
|
||
switch (granularity) {
|
||
case "year":
|
||
return key;
|
||
case "month":
|
||
return date.toLocaleDateString("en-US", { year: "numeric", month: "short" });
|
||
case "week":
|
||
const endOfWeek = new Date(date);
|
||
endOfWeek.setDate(date.getDate() + 6);
|
||
return `${date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${endOfWeek.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
||
case "day":
|
||
return date.toLocaleDateString("en-US", {
|
||
weekday: "short",
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
}
|
||
};
|
||
|
||
const groups: { [key: string]: { items: any[]; date: Date } } = {};
|
||
sortedItems.forEach((row: any) => {
|
||
const date = new Date(row.occurred_start);
|
||
const key = getGroupKey(date);
|
||
if (!groups[key]) {
|
||
// For week, parse the start date from key
|
||
let groupDate = date;
|
||
if (granularity === "week") {
|
||
const parts = key.split("-");
|
||
groupDate = new Date(parseInt(parts[0]), parseInt(parts[2]) - 1, parseInt(parts[3]));
|
||
}
|
||
groups[key] = { items: [], date: groupDate };
|
||
}
|
||
groups[key].items.push(row);
|
||
});
|
||
|
||
return Object.entries(groups)
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([key, { items, date }]) => ({
|
||
key,
|
||
label: getGroupLabel(key, date),
|
||
items,
|
||
date,
|
||
}));
|
||
}, [sortedItems, granularity]);
|
||
|
||
// Get date range info
|
||
const dateRange = useMemo(() => {
|
||
if (sortedItems.length === 0) return null;
|
||
const first = new Date(sortedItems[0].occurred_start);
|
||
const last = new Date(sortedItems[sortedItems.length - 1].occurred_start);
|
||
return { first, last };
|
||
}, [sortedItems]);
|
||
|
||
// Navigation
|
||
const scrollToGroup = (index: number) => {
|
||
const clampedIndex = Math.max(0, Math.min(index, timelineGroups.length - 1));
|
||
setCurrentIndex(clampedIndex);
|
||
const element = document.getElementById(`timeline-group-${clampedIndex}`);
|
||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
};
|
||
|
||
const zoomIn = () => {
|
||
const levels: Granularity[] = ["year", "month", "week", "day"];
|
||
const currentIdx = levels.indexOf(granularity);
|
||
if (currentIdx < levels.length - 1) {
|
||
setGranularity(levels[currentIdx + 1]);
|
||
}
|
||
};
|
||
|
||
const zoomOut = () => {
|
||
const levels: Granularity[] = ["year", "month", "week", "day"];
|
||
const currentIdx = levels.indexOf(granularity);
|
||
if (currentIdx > 0) {
|
||
setGranularity(levels[currentIdx - 1]);
|
||
}
|
||
};
|
||
|
||
if (sortedItems.length === 0) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-12">
|
||
<Calendar className="w-12 h-12 text-muted-foreground mb-3" />
|
||
<div className="text-base font-medium text-foreground mb-1">No Timeline Data</div>
|
||
<div className="text-xs text-muted-foreground text-center max-w-md">
|
||
No memories have occurred_at dates.
|
||
{itemsWithoutDates.length > 0 && (
|
||
<span className="block mt-1">
|
||
{itemsWithoutDates.length} memories without dates in Table View.
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const formatDateTime = (dateStr: string) => {
|
||
const date = new Date(dateStr);
|
||
const dateFormatted = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||
const timeFormatted = date.toLocaleTimeString("en-US", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
});
|
||
return { date: dateFormatted, time: timeFormatted };
|
||
};
|
||
|
||
const granularityLabels: Record<Granularity, string> = {
|
||
year: "Year",
|
||
month: "Month",
|
||
week: "Week",
|
||
day: "Day",
|
||
};
|
||
|
||
return (
|
||
<div className="px-4">
|
||
{/* Timeline */}
|
||
<div>
|
||
{/* Controls */}
|
||
<div className="flex items-center justify-between mb-3 gap-4">
|
||
<div className="text-xs text-muted-foreground">
|
||
{sortedItems.length} memories
|
||
{itemsWithoutDates.length > 0 && ` · ${itemsWithoutDates.length} without dates`}
|
||
{dateRange && (
|
||
<span className="ml-2 text-foreground">
|
||
({dateRange.first.toLocaleDateString("en-US", { month: "short", year: "numeric" })}{" "}
|
||
→ {dateRange.last.toLocaleDateString("en-US", { month: "short", year: "numeric" })})
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
{/* Zoom controls */}
|
||
<div className="flex items-center border border-border rounded mr-2">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={zoomOut}
|
||
disabled={granularity === "year"}
|
||
className="h-7 w-7 p-0"
|
||
title="Zoom out"
|
||
>
|
||
<ZoomOut className="h-3 w-3" />
|
||
</Button>
|
||
<span className="text-[10px] px-2 min-w-[50px] text-center border-x border-border text-foreground">
|
||
{granularityLabels[granularity]}
|
||
</span>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={zoomIn}
|
||
disabled={granularity === "day"}
|
||
className="h-7 w-7 p-0"
|
||
title="Zoom in"
|
||
>
|
||
<ZoomIn className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Navigation controls */}
|
||
<div className="flex items-center border border-border rounded">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => scrollToGroup(0)}
|
||
disabled={timelineGroups.length <= 1}
|
||
className="h-7 w-7 p-0"
|
||
title="First"
|
||
>
|
||
<ChevronsLeft className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => scrollToGroup(currentIndex - 1)}
|
||
disabled={currentIndex === 0}
|
||
className="h-7 w-7 p-0"
|
||
title="Previous"
|
||
>
|
||
<ChevronLeft className="h-3 w-3" />
|
||
</Button>
|
||
<span className="text-[10px] px-2 min-w-[60px] text-center border-x border-border text-foreground">
|
||
{currentIndex + 1} / {timelineGroups.length}
|
||
</span>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => scrollToGroup(currentIndex + 1)}
|
||
disabled={currentIndex >= timelineGroups.length - 1}
|
||
className="h-7 w-7 p-0"
|
||
title="Next"
|
||
>
|
||
<ChevronRight className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => scrollToGroup(timelineGroups.length - 1)}
|
||
disabled={timelineGroups.length <= 1}
|
||
className="h-7 w-7 p-0"
|
||
title="Last"
|
||
>
|
||
<ChevronsRight className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div ref={timelineRef} className="relative max-h-[550px] overflow-y-auto pr-2">
|
||
{/* Vertical line */}
|
||
<div className="absolute left-[60px] top-0 bottom-0 w-0.5 bg-border" />
|
||
|
||
{timelineGroups.map((group, groupIdx) => (
|
||
<div key={group.key} id={`timeline-group-${groupIdx}`} className="mb-4">
|
||
{/* Group header */}
|
||
<div
|
||
className="flex items-center mb-2 cursor-pointer hover:opacity-80"
|
||
onClick={() => setCurrentIndex(groupIdx)}
|
||
>
|
||
<div className="w-[60px] text-right pr-3">
|
||
<span className="text-xs font-semibold text-primary">{group.label}</span>
|
||
</div>
|
||
<div className="w-2 h-2 rounded-full bg-primary z-10" />
|
||
<span className="ml-2 text-[10px] text-muted-foreground">
|
||
{group.items.length} {group.items.length === 1 ? "item" : "items"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Items in this month */}
|
||
<div className="space-y-1">
|
||
{group.items.map((item: any, idx: number) => (
|
||
<div
|
||
key={item.id || idx}
|
||
onClick={() => setSelectedItem(item)}
|
||
className={`flex items-start cursor-pointer group ${
|
||
selectedItem?.id === item.id ? "opacity-100" : "hover:opacity-80"
|
||
}`}
|
||
>
|
||
{/* Date & Time */}
|
||
<div className="w-[60px] text-right pr-3 pt-1 flex-shrink-0">
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{formatDateTime(item.occurred_start).date}
|
||
</div>
|
||
<div className="text-[9px] text-muted-foreground/70">
|
||
{formatDateTime(item.occurred_start).time}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Connector dot */}
|
||
<div className="flex-shrink-0 pt-2">
|
||
<div
|
||
className={`w-1.5 h-1.5 rounded-full z-10 ${
|
||
selectedItem?.id === item.id
|
||
? "bg-primary"
|
||
: "bg-muted-foreground/50 group-hover:bg-primary"
|
||
}`}
|
||
/>
|
||
</div>
|
||
|
||
{/* Card */}
|
||
<div
|
||
className={`ml-3 flex-1 p-2 rounded border transition-colors ${
|
||
selectedItem?.id === item.id
|
||
? "bg-primary/10 border-primary"
|
||
: "bg-card border-border hover:border-primary/50"
|
||
}`}
|
||
>
|
||
<p className="text-xs text-foreground line-clamp-2 leading-relaxed">
|
||
{item.text}
|
||
</p>
|
||
{item.context && (
|
||
<p className="text-[10px] text-muted-foreground mt-1 truncate">
|
||
{item.context}
|
||
</p>
|
||
)}
|
||
{item.entities && (
|
||
<div className="flex gap-1 mt-1 flex-wrap">
|
||
{item.entities
|
||
.split(", ")
|
||
.slice(0, 3)
|
||
.map((entity: string, i: number) => (
|
||
<span
|
||
key={i}
|
||
className="text-[9px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium"
|
||
>
|
||
{entity}
|
||
</span>
|
||
))}
|
||
{item.entities.split(", ").length > 3 && (
|
||
<span className="text-[9px] text-muted-foreground">
|
||
+{item.entities.split(", ").length - 3}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detail Panel - Fixed on Right */}
|
||
{selectedItem && (
|
||
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
|
||
<MemoryDetailPanel
|
||
memory={selectedItem}
|
||
onClose={() => setSelectedItem(null)}
|
||
inPanel
|
||
bankId={bankId}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|