Files
DeskClaw/src/components/layout/Sidebar.tsx

401 lines
15 KiB
TypeScript

/**
* Sidebar Component
* Navigation sidebar with menu items.
* No longer fixed - sits inside the flex layout below the title bar.
*/
import { useEffect, useMemo, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Network,
Bot,
Puzzle,
Clock,
Settings as SettingsIcon,
PanelLeftClose,
PanelLeft,
Plus,
Terminal,
ExternalLink,
Trash2,
Cpu,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings';
import { useChatStore } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { hostApiFetch } from '@/lib/host-api';
import { useTranslation } from 'react-i18next';
import logoSvg from '@/assets/logo.svg';
type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
interface NavItemProps {
to: string;
icon: React.ReactNode;
label: string;
badge?: string;
collapsed?: boolean;
onClick?: () => void;
testId?: string;
}
function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItemProps) {
return (
<NavLink
to={to}
onClick={onClick}
data-testid={testId}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
isActive
? 'bg-black/5 dark:bg-white/10 text-foreground'
: '',
collapsed && 'justify-center px-0'
)
}
>
{({ isActive }) => (
<>
<div className={cn("flex shrink-0 items-center justify-center", isActive ? "text-foreground" : "text-muted-foreground")}>
{icon}
</div>
{!collapsed && (
<>
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
{badge && (
<Badge variant="secondary" className="ml-auto shrink-0">
{badge}
</Badge>
)}
</>
)}
</>
)}
</NavLink>
);
}
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
const INITIAL_NOW_MS = Date.now();
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
const [, agentId] = sessionKey.split(':');
return agentId || 'main';
}
export function Sidebar() {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const sessions = useChatStore((s) => s.sessions);
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
const sessionLabels = useChatStore((s) => s.sessionLabels);
const sessionLastActivity = useChatStore((s) => s.sessionLastActivity);
const switchSession = useChatStore((s) => s.switchSession);
const newSession = useChatStore((s) => s.newSession);
const deleteSession = useChatStore((s) => s.deleteSession);
const loadSessions = useChatStore((s) => s.loadSessions);
const loadHistory = useChatStore((s) => s.loadHistory);
const gatewayStatus = useGatewayStore((s) => s.status);
const isGatewayRunning = gatewayStatus.state === 'running';
useEffect(() => {
if (!isGatewayRunning) return;
let cancelled = false;
const hasExistingMessages = useChatStore.getState().messages.length > 0;
(async () => {
await loadSessions();
if (cancelled) return;
await loadHistory(hasExistingMessages);
})();
return () => {
cancelled = true;
};
}, [isGatewayRunning, loadHistory, loadSessions]);
const agents = useAgentsStore((s) => s.agents);
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
const navigate = useNavigate();
const isOnChat = useLocation().pathname === '/';
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
sessionLabels[key] ?? label ?? displayName ?? key;
const openDevConsole = async () => {
try {
const result = await hostApiFetch<{
success: boolean;
url?: string;
error?: string;
}>('/api/gateway/control-ui');
if (result.success && result.url) {
window.electron.openExternal(result.url);
} else {
console.error('Failed to get Dev Console URL:', result.error);
}
} catch (err) {
console.error('Error opening Dev Console:', err);
}
};
const { t } = useTranslation(['common', 'chat']);
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 60 * 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
void fetchAgents();
}, [fetchAgents]);
const agentNameById = useMemo(
() => Object.fromEntries((agents ?? []).map((agent) => [agent.id, agent.name])),
[agents],
);
const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
{ key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] },
{ key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] },
{ key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] },
{ key: 'older', label: t('chat:historyBuckets.older'), sessions: [] },
];
const sessionBucketMap = Object.fromEntries(sessionBuckets.map((bucket) => [bucket.key, bucket])) as Record<
SessionBucketKey,
(typeof sessionBuckets)[number]
>;
for (const session of [...sessions].sort((a, b) =>
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
)) {
const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs);
sessionBucketMap[bucketKey].sessions.push(session);
}
const navItems = [
{ to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
{ to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
{ to: '/skills', icon: <Puzzle className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.skills'), testId: 'sidebar-nav-skills' },
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
];
return (
<aside
data-testid="sidebar"
className={cn(
'flex shrink-0 flex-col border-r bg-[#eae8e1]/60 dark:bg-background transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64'
)}
>
{/* Top Header Toggle */}
<div className={cn("flex items-center p-2 h-12", sidebarCollapsed ? "justify-center" : "justify-between")}>
{!sidebarCollapsed && (
<div className="flex items-center gap-2 px-2 overflow-hidden">
<img src={logoSvg} alt="ClawX" className="h-5 w-auto shrink-0" />
<span className="text-sm font-semibold truncate whitespace-nowrap text-foreground/90">
ClawX
</span>
</div>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? (
<PanelLeft className="h-[18px] w-[18px]" />
) : (
<PanelLeftClose className="h-[18px] w-[18px]" />
)}
</Button>
</div>
{/* Navigation */}
<nav className="flex flex-col px-2 gap-0.5">
<button
data-testid="sidebar-new-chat"
onClick={() => {
const { messages } = useChatStore.getState();
if (messages.length > 0) newSession();
navigate('/');
}}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors mb-2',
'bg-black/5 dark:bg-accent shadow-none border border-transparent text-foreground',
sidebarCollapsed && 'justify-center px-0',
)}
>
<div className="flex shrink-0 items-center justify-center text-foreground/80">
<Plus className="h-[18px] w-[18px]" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.newChat')}</span>}
</button>
{navItems.map((item) => (
<NavItem
key={item.to}
{...item}
collapsed={sidebarCollapsed}
/>
))}
</nav>
{/* Session list — below Settings, only when expanded */}
{!sidebarCollapsed && sessions.length > 0 && (
<div className="flex-1 overflow-y-auto overflow-x-hidden px-2 mt-4 space-y-0.5 pb-2">
{sessionBuckets.map((bucket) => (
bucket.sessions.length > 0 ? (
<div key={bucket.key} className="pt-2">
<div className="px-2.5 pb-1 text-[11px] font-medium text-muted-foreground/60 tracking-tight">
{bucket.label}
</div>
{bucket.sessions.map((s) => {
const agentId = getAgentIdFromSessionKey(s.key);
const agentName = agentNameById[agentId] || agentId;
return (
<div key={s.key} className="group relative flex items-center">
<button
onClick={() => { switchSession(s.key); navigate('/'); }}
className={cn(
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] transition-colors pr-7',
'hover:bg-black/5 dark:hover:bg-white/5',
isOnChat && currentSessionKey === s.key
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
: 'text-foreground/75',
)}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 rounded-full bg-black/[0.04] px-2 py-0.5 text-[10px] font-medium text-foreground/70 dark:bg-white/[0.08]">
{agentName}
</span>
<span className="truncate">{getSessionLabel(s.key, s.displayName, s.label)}</span>
</div>
</button>
<button
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: getSessionLabel(s.key, s.displayName, s.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
) : null
))}
</div>
)}
{/* Footer */}
<div className="p-2 mt-auto">
<NavLink
to="/settings"
data-testid="sidebar-nav-settings"
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
isActive && 'bg-black/5 dark:bg-white/10 text-foreground',
sidebarCollapsed ? 'justify-center px-0' : ''
)
}
>
{({ isActive }) => (
<>
<div className={cn("flex shrink-0 items-center justify-center", isActive ? "text-foreground" : "text-muted-foreground")}>
<SettingsIcon className="h-[18px] w-[18px]" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.settings')}</span>}
</>
)}
</NavLink>
<Button
data-testid="sidebar-open-dev-console"
variant="ghost"
className={cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-[14px] font-medium transition-colors w-full mt-1',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
)}
onClick={openDevConsole}
>
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
<Terminal className="h-[18px] w-[18px]" strokeWidth={2} />
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
<ExternalLink className="h-3 w-3 shrink-0 ml-auto opacity-50 text-muted-foreground" />
</>
)}
</Button>
</div>
<ConfirmDialog
open={!!sessionToDelete}
title={t('common:actions.confirm')}
message={t('common:sidebar.deleteSessionConfirm', { label: sessionToDelete?.label })}
confirmLabel={t('common:actions.delete')}
cancelLabel={t('common:actions.cancel')}
variant="destructive"
onConfirm={async () => {
if (!sessionToDelete) return;
await deleteSession(sessionToDelete.key);
if (currentSessionKey === sessionToDelete.key) navigate('/');
setSessionToDelete(null);
}}
onCancel={() => setSessionToDelete(null)}
/>
</aside>
);
}