/** * Memory Tree status panel — 4 stat tiles + on/off toggle. * * Replaces the temporary `useConsciousItems`-driven pill in * `Intelligence.tsx`; addresses issue #1856 Part 1. * * The toggle writes `config.scheduler_gate.mode = "off"` via the * `memory_tree_set_enabled` RPC and relies on the scheduler-gate * hot-reload to pause all LLM-bound background work cooperatively. * It does NOT pause the 20-min Composio fetch loop yet (#1856 Part 2 * follow-up). * * Polling cadence mirrors `useMemoryIngestionStatus`: 1.5s while * syncing/active jobs, 4s otherwise — the same heuristic we use * elsewhere so the dashboard feels lively without thrashing the * core. * * Layout & color conventions copied verbatim from * `MemoryStatsBar.tsx` (tiles) and the inline `ToggleRow` in * `settings/panels/AIPanel.tsx` (switch markup). */ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import type { ToastNotification } from '../../types/intelligence'; import { memoryTreePipelineStatus, type MemoryTreePipelineStatus, memoryTreeSetEnabled, } from '../../utils/tauriCommands'; /** Translator function shape exposed by `useT()`. */ type TFn = (key: string, fallback?: string) => string; /** * Adaptive polling cadence — match the existing memory ingestion * panel so the two surfaces feel like one. */ const FAST_POLL_MS = 1500; const DEFAULT_POLL_MS = 4000; /** * Public hook so unit tests (and any future caller) can subscribe to the * pipeline-status stream without re-implementing the polling dance. */ function useMemoryTreeStatus(): { status: MemoryTreePipelineStatus | null; loading: boolean; error: string | null; refresh: () => Promise; } { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const cancelledRef = useRef(false); const statusRef = useRef(null); statusRef.current = status; const fetchOnce = useCallback(async () => { console.debug('[ui-flow][memory-tree-status] fetchOnce: entry'); try { const next = await memoryTreePipelineStatus(); if (cancelledRef.current) return; setStatus(next); setError(null); console.debug( '[ui-flow][memory-tree-status] fetchOnce: ok status=%s total=%d', next.status, next.total_chunks ); } catch (err) { if (cancelledRef.current) return; const message = err instanceof Error ? err.message : String(err); console.warn('[ui-flow][memory-tree-status] fetchOnce: error %s', message); setError(message); } finally { if (!cancelledRef.current) setLoading(false); } }, []); useEffect(() => { cancelledRef.current = false; let timer: ReturnType | null = null; const tick = async () => { await fetchOnce(); if (cancelledRef.current) return; const live = statusRef.current; const fast = live?.is_syncing || (live?.pipeline_jobs?.running ?? 0) > 0; timer = setTimeout(tick, fast ? FAST_POLL_MS : DEFAULT_POLL_MS); }; void tick(); return () => { cancelledRef.current = true; if (timer) clearTimeout(timer); }; }, [fetchOnce]); return { status, loading, error, refresh: fetchOnce }; } interface MemoryTreeStatusPanelProps { onToast?: (toast: Omit) => void; } /** * Format a millisecond timestamp as a coarse "5 min ago" style label. * Returns the localized `Never` placeholder when `ms` is 0/falsy. * * Intentionally light — no dayjs dependency, no plural rules. Buckets * (just-now / seconds / minutes / hours / days) are enough for the status * tile; the precise timestamp is one level deeper in the workspace UI. * * Strings flow through `t()` from `useT()` so the panel localizes * cleanly. `{count}` placeholders are substituted client-side because * `t()` does not interpolate (see `I18nContext.tsx`). */ function formatRelativeMs(ms: number, t: TFn, neverLabel: string): string { if (!ms || ms <= 0) return neverLabel; const diffMs = Date.now() - ms; if (diffMs < 0) return neverLabel; // clock skew safety const sec = Math.floor(diffMs / 1000); if (sec < 30) return t('memoryTree.status.justNow'); if (sec < 60) return t('memoryTree.status.secondsAgo').replace('{count}', String(sec)); const min = Math.floor(sec / 60); if (min < 60) { if (min === 1) return t('memoryTree.status.minuteAgo'); return t('memoryTree.status.minutesAgo').replace('{count}', String(min)); } const hr = Math.floor(min / 60); if (hr < 24) { if (hr === 1) return t('memoryTree.status.hourAgo'); return t('memoryTree.status.hoursAgo').replace('{count}', String(hr)); } const day = Math.floor(hr / 24); if (day === 1) return t('memoryTree.status.dayAgo'); return t('memoryTree.status.daysAgo').replace('{count}', String(day)); } /** * Format a raw byte count as KiB / MiB / GiB — sized to the order of * magnitude. Negative / zero ⇒ `0 B`. */ function formatBytes(n: number): string { if (!n || n <= 0) return '0 B'; const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let v = n; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } // 1 decimal place once we're past bytes, integer for plain bytes. return `${i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; } /** Map the wire status to a dot color token + animation flag. */ function statusDotClass(kind: MemoryTreePipelineStatus['status']): string { switch (kind) { case 'running': return 'bg-sage-400'; case 'syncing': return 'bg-sage-500 animate-pulse'; case 'paused': return 'bg-stone-400 dark:bg-neutral-500'; case 'error': return 'bg-coral-500'; case 'idle': default: return 'bg-stone-400 dark:bg-neutral-500'; } } /** * Memory Tree status panel — render the four-tile dashboard plus the * auto-sync toggle. Designed to mount above `` in * `MemoryWorkspace` so it surfaces in both the Intelligence page and * Settings → Memory data without extra wiring. */ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { const { t } = useT(); const { status, loading, error, refresh } = useMemoryTreeStatus(); const [toggleBusy, setToggleBusy] = useState(false); const handleToggle = useCallback(async () => { if (!status || toggleBusy) return; const nextEnabled = status.is_paused; // currently paused ⇒ enable console.debug('[ui-flow][memory-tree-status] toggle: entry next_enabled=%s', nextEnabled); setToggleBusy(true); try { await memoryTreeSetEnabled(nextEnabled); await refresh(); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn('[ui-flow][memory-tree-status] toggle: error %s', message); onToast?.({ type: 'error', title: t('memoryTree.status.toggleFailed'), message }); } finally { setToggleBusy(false); } }, [status, toggleBusy, refresh, onToast, t]); const statusKind = status?.status ?? 'idle'; const statusLabel: string = (() => { switch (statusKind) { case 'running': return t('memoryTree.status.statusRunning'); case 'paused': return t('memoryTree.status.statusPaused'); case 'syncing': return t('memoryTree.status.statusSyncing'); case 'error': return t('memoryTree.status.statusError'); case 'idle': default: return t('memoryTree.status.statusIdle'); } })(); const checked = !(status?.is_paused ?? false); const tileClass = 'rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800'; const labelClass = 'text-[11px] uppercase tracking-wide text-stone-500 dark:text-neutral-400 mb-1'; const valueClass = 'text-xl font-semibold text-stone-900 dark:text-neutral-100'; const skeletonClass = 'h-7 w-16 rounded bg-stone-200 dark:bg-neutral-800 animate-pulse'; return (

{t('memoryTree.status.title')}

{error && !loading ? (
{t('memoryTree.status.fetchError')}
) : null}
{/* Status tile ── color-coded pill */}
{t('memoryTree.status.statusTile')}
{loading || !status ? (
) : ( <>
{statusLabel}
{status.reason ? (
{status.reason}
) : null} )}
{/* Last-sync tile */}
{t('memoryTree.status.lastSyncTile')}
{loading || !status ? (
) : (
{formatRelativeMs(status.last_sync_ms, t, t('memoryTree.status.never'))}
)}
{/* Total chunks tile */}
{t('memoryTree.status.totalChunksTile')}
{loading || !status ? (
) : (
{new Intl.NumberFormat().format(status.total_chunks)}
)}
{/* Wiki size tile */}
{t('memoryTree.status.wikiSizeTile')}
{loading || !status ? (
) : (
{formatBytes(status.wiki_size_bytes)}
)}
{/* Auto-sync toggle row — markup mirrors AIPanel's inline ToggleRow */}
{t('memoryTree.status.autoSyncLabel')}
{t('memoryTree.status.autoSyncDescription')}
); } // Re-export the hook so unit tests can opt into the polling subscription // directly without re-implementing it. export { useMemoryTreeStatus };