2026-02-08 19:11:36 -08:00

371 lines
11 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
// --- Types ---
type WebSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
};
type SkillEntry = {
name: string;
description: string;
emoji?: string;
source: string;
};
type MemoryFile = {
name: string;
sizeBytes: number;
};
type SidebarSection = "chats" | "skills" | "memories";
type SidebarProps = {
onSessionSelect?: (sessionId: string) => void;
onNewSession?: () => void;
activeSessionId?: string;
refreshKey?: number;
};
// --- Helpers ---
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
// --- Section Components ---
function ChatsSection({
sessions,
onSessionSelect,
activeSessionId,
}: {
sessions: WebSession[];
onSessionSelect?: (sessionId: string) => void;
activeSessionId?: string;
}) {
const [searchTerm, setSearchTerm] = useState("");
const filteredSessions = sessions.filter((s) =>
s.title.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="space-y-2">
{sessions.length > 3 && (
<div className="px-3">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search chats..."
className="w-full px-3 py-1.5 text-xs bg-[var(--color-bg)] border border-[var(--color-border)] rounded-md text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-transparent"
/>
</div>
)}
{filteredSessions.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)] px-3">
{searchTerm ? "No matching chats." : "No chats yet. Send a message to start."}
</p>
) : (
<div className="space-y-0.5">
{filteredSessions.map((s) => {
const isActive = s.id === activeSessionId;
return (
<div
key={s.id}
onClick={() => onSessionSelect?.(s.id)}
className={`mx-2 px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] cursor-pointer transition-colors ${
isActive
? "bg-[var(--color-surface-hover)] border-l-2 border-[var(--color-accent)]"
: ""
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{s.title}</span>
<span className="text-xs text-[var(--color-text-muted)] flex-shrink-0">
{timeAgo(s.updatedAt)}
</span>
</div>
{s.messageCount > 0 && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
{s.messageCount} message{s.messageCount !== 1 ? "s" : ""}
</p>
)}
</div>
);
})}
</div>
)}
</div>
);
}
function SkillsSection({ skills }: { skills: SkillEntry[] }) {
if (skills.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)] px-3">No skills found.</p>;
}
return (
<div className="space-y-1">
{skills.map((skill) => (
<div
key={`${skill.source}:${skill.name}`}
className="px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors"
>
<div className="flex items-center gap-2">
{skill.emoji && <span className="text-base">{skill.emoji}</span>}
<span className="text-sm font-medium">{skill.name}</span>
<span className="text-xs text-[var(--color-text-muted)] ml-auto">{skill.source}</span>
</div>
{skill.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{skill.description}
</p>
)}
</div>
))}
</div>
);
}
function MemoriesSection({
mainMemory,
dailyLogs,
}: {
mainMemory: string | null;
dailyLogs: MemoryFile[];
}) {
const [expanded, setExpanded] = useState(false);
return (
<div className="space-y-2">
{mainMemory ? (
<div className="px-3">
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-[var(--color-accent)] hover:text-[var(--color-accent-hover)] mb-1"
>
{expanded ? "Collapse" : "Show"} MEMORY.md ({mainMemory.length} chars)
</button>
{expanded && (
<pre className="text-xs text-[var(--color-text-muted)] bg-[var(--color-bg)] rounded p-2 overflow-auto max-h-64 whitespace-pre-wrap">
{mainMemory}
</pre>
)}
</div>
) : (
<p className="text-sm text-[var(--color-text-muted)] px-3">No MEMORY.md found.</p>
)}
{dailyLogs.length > 0 && (
<div className="px-3">
<p className="text-xs text-[var(--color-text-muted)] mb-1">
Daily logs ({dailyLogs.length})
</p>
<div className="space-y-0.5">
{dailyLogs.slice(0, 10).map((log) => (
<div
key={log.name}
className="text-xs text-[var(--color-text-muted)] flex justify-between"
>
<span>{log.name}</span>
<span>{(log.sizeBytes / 1024).toFixed(1)}kb</span>
</div>
))}
{dailyLogs.length > 10 && (
<p className="text-xs text-[var(--color-text-muted)]">
...and {dailyLogs.length - 10} more
</p>
)}
</div>
</div>
)}
</div>
);
}
// --- Collapsible Header ---
function SectionHeader({
title,
count,
isOpen,
onToggle,
}: {
title: string;
count?: number;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
>
<span>
{title}
{count != null && (
<span className="ml-1.5 text-xs text-[var(--color-text-muted)] font-normal">
({count})
</span>
)}
</span>
<svg
className={`w-4 h-4 text-[var(--color-text-muted)] transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
}
// --- Main Sidebar ---
export function Sidebar({
onSessionSelect,
onNewSession,
activeSessionId,
refreshKey,
}: SidebarProps) {
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats"]));
const [webSessions, setWebSessions] = useState<WebSession[]>([]);
const [skills, setSkills] = useState<SkillEntry[]>([]);
const [mainMemory, setMainMemory] = useState<string | null>(null);
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
const [loading, setLoading] = useState(true);
const toggleSection = (section: SidebarSection) => {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
};
// Fetch sidebar data (re-runs when refreshKey changes)
useEffect(() => {
async function load() {
setLoading(true);
try {
const [webSessionsRes, skillsRes, memoriesRes] = await Promise.all([
fetch("/api/web-sessions").then((r) => r.json()),
fetch("/api/skills").then((r) => r.json()),
fetch("/api/memories").then((r) => r.json()),
]);
setWebSessions(webSessionsRes.sessions ?? []);
setSkills(skillsRes.skills ?? []);
setMainMemory(memoriesRes.mainMemory ?? null);
setDailyLogs(memoriesRes.dailyLogs ?? []);
} catch (err) {
console.error("Failed to load sidebar data:", err);
} finally {
setLoading(false);
}
}
load();
}, [refreshKey]);
return (
<aside className="w-72 h-screen flex flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] overflow-hidden">
{/* Header with New Chat button */}
<div className="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between">
<h1 className="text-base font-bold flex items-center gap-2">
<span className="text-xl">🦞</span>
<span>OpenClaw</span>
</h1>
<button
onClick={onNewSession}
title="New Chat"
className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto py-2 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-[var(--color-border)] border-t-[var(--color-accent)] rounded-full animate-spin" />
</div>
) : (
<>
{/* Chats (web sessions) */}
<div>
<SectionHeader
title="Chats"
count={webSessions.length}
isOpen={openSections.has("chats")}
onToggle={() => toggleSection("chats")}
/>
{openSections.has("chats") && (
<ChatsSection
sessions={webSessions}
onSessionSelect={onSessionSelect}
activeSessionId={activeSessionId}
/>
)}
</div>
{/* Skills */}
<div>
<SectionHeader
title="Skills"
count={skills.length}
isOpen={openSections.has("skills")}
onToggle={() => toggleSection("skills")}
/>
{openSections.has("skills") && <SkillsSection skills={skills} />}
</div>
{/* Memories */}
<div>
<SectionHeader
title="Memories"
count={dailyLogs.length}
isOpen={openSections.has("memories")}
onToggle={() => toggleSection("memories")}
/>
{openSections.has("memories") && (
<MemoriesSection mainMemory={mainMemory} dailyLogs={dailyLogs} />
)}
</div>
</>
)}
</div>
</aside>
);
}