import React, { useState, useEffect, useCallback, useRef, useMemo, } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Calendar, Trash2, Plus, Search, X, FolderOpen } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import MarkdownEditor from "@/components/MarkdownEditor"; import { workspaceApi, type DailyMemoryFileInfo, type DailyMemorySearchResult, } from "@/lib/api/workspace"; interface DailyMemoryPanelProps { isOpen: boolean; onClose: () => void; } function getTodayFilename(): string { const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); return `${y}-${m}-${d}.md`; } function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } const DailyMemoryPanel: React.FC = ({ isOpen, onClose, }) => { const { t } = useTranslation(); // List state const [files, setFiles] = useState([]); const [loadingList, setLoadingList] = useState(false); // Edit state const [editingFile, setEditingFile] = useState(null); const [content, setContent] = useState(""); const [loadingContent, setLoadingContent] = useState(false); const [saving, setSaving] = useState(false); // Delete state const [deletingFile, setDeletingFile] = useState(null); // Search state const [searchTerm, setSearchTerm] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const [searchResults, setSearchResults] = useState( [], ); const [searching, setSearching] = useState(false); const searchInputRef = useRef(null); const debounceTimerRef = useRef | null>(null); // Dark mode const [isDarkMode, setIsDarkMode] = useState(false); useEffect(() => { setIsDarkMode(document.documentElement.classList.contains("dark")); const observer = new MutationObserver(() => { setIsDarkMode(document.documentElement.classList.contains("dark")); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); return () => observer.disconnect(); }, []); // Whether we are in active search mode (search open with a non-empty term) const isActiveSearch = useMemo( () => isSearchOpen && searchTerm.trim().length > 0, [isSearchOpen, searchTerm], ); // Debounced search execution const executeSearch = useCallback( async (query: string) => { if (!query.trim()) { setSearchResults([]); setSearching(false); return; } setSearching(true); try { const results = await workspaceApi.searchDailyMemoryFiles(query.trim()); setSearchResults(results); } catch (err) { console.error("Failed to search daily memory files:", err); toast.error(t("workspace.dailyMemory.searchFailed")); } finally { setSearching(false); } }, [t], ); // Handle search input change with debounce const handleSearchChange = useCallback( (value: string) => { setSearchTerm(value); if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = setTimeout(() => { void executeSearch(value); }, 300); }, [executeSearch], ); // Open search bar const openSearch = useCallback(() => { setIsSearchOpen(true); // Focus input on next frame requestAnimationFrame(() => { searchInputRef.current?.focus(); }); }, []); // Close search bar and clear state const closeSearch = useCallback(() => { setIsSearchOpen(false); setSearchTerm(""); setSearchResults([]); setSearching(false); if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }, []); // Keyboard shortcut: Cmd/Ctrl+F to open search, Escape to close useEffect(() => { if (!isOpen || editingFile) return; const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "f") { e.preventDefault(); if (!isSearchOpen) { openSearch(); } else { searchInputRef.current?.focus(); } } if (e.key === "Escape" && isSearchOpen) { e.preventDefault(); closeSearch(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isOpen, editingFile, isSearchOpen, openSearch, closeSearch]); // Clean up debounce timer on unmount useEffect(() => { return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }; }, []); // Load file list const loadFiles = useCallback(async () => { setLoadingList(true); try { const list = await workspaceApi.listDailyMemoryFiles(); setFiles(list); } catch (err) { console.error("Failed to load daily memory files:", err); toast.error(t("workspace.dailyMemory.loadFailed")); } finally { setLoadingList(false); } }, [t]); useEffect(() => { if (isOpen) { void loadFiles(); } }, [isOpen, loadFiles]); // Open file for editing const openFile = useCallback( async (filename: string) => { setLoadingContent(true); setEditingFile(filename); try { const data = await workspaceApi.readDailyMemoryFile(filename); setContent(data ?? ""); } catch (err) { console.error("Failed to read daily memory file:", err); toast.error(t("workspace.dailyMemory.loadFailed")); setEditingFile(null); } finally { setLoadingContent(false); } }, [t], ); // Create today's note (deferred — file is only persisted on save) const handleCreateToday = useCallback(async () => { const filename = getTodayFilename(); // Check if already exists in the list const existing = files.find((f) => f.filename === filename); if (existing) { // Just open it await openFile(filename); return; } // Open editor with empty content — no file created until user saves setEditingFile(filename); setContent(""); }, [files, openFile]); // Save current file const handleSave = useCallback(async () => { if (!editingFile) return; setSaving(true); try { await workspaceApi.writeDailyMemoryFile(editingFile, content); toast.success(t("workspace.saveSuccess")); } catch (err) { console.error("Failed to save daily memory file:", err); toast.error(t("workspace.saveFailed")); } finally { setSaving(false); } }, [editingFile, content, t]); // Delete file const handleDelete = useCallback(async () => { if (!deletingFile) return; try { await workspaceApi.deleteDailyMemoryFile(deletingFile); toast.success(t("workspace.dailyMemory.deleteSuccess")); setDeletingFile(null); // If we were editing this file, go back to list if (editingFile === deletingFile) { setEditingFile(null); } await loadFiles(); // Re-trigger search if active if (isSearchOpen && searchTerm.trim()) { void executeSearch(searchTerm); } } catch (err) { console.error("Failed to delete daily memory file:", err); toast.error(t("workspace.dailyMemory.deleteFailed")); setDeletingFile(null); } }, [ deletingFile, editingFile, loadFiles, t, isSearchOpen, searchTerm, executeSearch, ]); // Back from edit mode to list mode — preserve search state const handleBackToList = useCallback(() => { setEditingFile(null); setContent(""); void loadFiles(); // Re-trigger search if active (file content may have changed) if (isSearchOpen && searchTerm.trim()) { void executeSearch(searchTerm); } }, [loadFiles, isSearchOpen, searchTerm, executeSearch]); // Close panel entirely — clear search state const handleClose = useCallback(() => { setEditingFile(null); setContent(""); setIsSearchOpen(false); setSearchTerm(""); setSearchResults([]); setSearching(false); onClose(); }, [onClose]); // --- Edit mode --- if (editingFile) { return ( <> {saving ? t("common.saving") : t("common.save")} } > {loadingContent ? (
{t("prompts.loading")}
) : ( )}
setDeletingFile(null)} /> ); } // --- List mode --- return ( <>
{/* Header with path, search, and create button */}

workspaceApi.openDirectory("memory")} title={t("workspace.openDirectory")} > ~/.openclaw/workspace/memory/

{/* Search bar */} {isSearchOpen && (
handleSearchChange(e.target.value)} placeholder={t("workspace.dailyMemory.searchPlaceholder")} className="pl-8 pr-8 h-8 text-sm" /> {searchTerm && ( )}
)}
{/* Content: search results or normal file list */} {isActiveSearch ? ( // --- Search results --- searching ? (
{t("workspace.dailyMemory.searching")}
) : searchResults.length === 0 ? (

{t("workspace.dailyMemory.noSearchResults")}

) : (
{searchResults.map((result) => ( ))}
) ) : // --- Normal file list --- loadingList ? (
{t("prompts.loading")}
) : files.length === 0 ? (

{t("workspace.dailyMemory.empty")}

) : (
{files.map((file) => ( ))}
)}
setDeletingFile(null)} /> ); }; export default DailyMemoryPanel;