diff --git a/app/src/components/intelligence/MemoryGraph.tsx b/app/src/components/intelligence/MemoryGraph.tsx index 1cadfb1ca6..46e7406d79 100644 --- a/app/src/components/intelligence/MemoryGraph.tsx +++ b/app/src/components/intelligence/MemoryGraph.tsx @@ -45,7 +45,7 @@ import { levelColor, nodeColor, nodeRadius, - supportsWebGL, + SOURCE_COLOR, VIEWPORT_H, VIEWPORT_W, ZOOM_MAX, @@ -54,8 +54,18 @@ import { import { summaryWorkspacePath } from './memoryWorkspacePaths'; import { PixiGraph } from './PixiGraph'; -/** Detected once — WebGL availability decides Pixi vs the SVG fallback. */ -const HAS_WEBGL = supportsWebGL(); +/** Use WebGL (Pixi) in production; fall back to SVG in test (jsdom). */ +const HAS_WEBGL = + typeof document !== 'undefined' && + typeof document.createElement === 'function' && + (() => { + try { + const c = document.createElement('canvas'); + return !!(c.getContext('webgl2') || c.getContext('webgl')); + } catch { + return false; + } + })(); interface SimNode extends GraphNode { x: number; @@ -357,6 +367,9 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps) const legend = mode === 'tree' ? [ + ...(nodes.some(n => n.kind === 'source') + ? [{ label: t('graph.source', 'Source'), color: SOURCE_COLOR }] + : []), ...Array.from(new Set(nodes.filter(n => n.kind === 'summary').map(n => n.level ?? 0))) .sort((a, b) => a - b) .map(lvl => ({ label: `L${lvl}`, color: levelColor(lvl) })), @@ -486,7 +499,11 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
- {hovered.kind === 'summary' ? ( + {hovered.kind === 'source' ? ( + + {hovered.label} + + ) : hovered.kind === 'summary' ? ( <> L{hovered.level ?? '?'} · diff --git a/app/src/components/intelligence/MemorySourcesRegistry.tsx b/app/src/components/intelligence/MemorySourcesRegistry.tsx index 892937bd46..988a6e0f0b 100644 --- a/app/src/components/intelligence/MemorySourcesRegistry.tsx +++ b/app/src/components/intelligence/MemorySourcesRegistry.tsx @@ -24,6 +24,7 @@ import { updateMemorySource, } from '../../services/memorySourcesService'; import type { ToastNotification } from '../../types/intelligence'; +import { memoryTreeFlushSource } from '../../utils/tauriCommands/memoryTree'; import { AddMemorySourceDialog } from './AddMemorySourceDialog'; interface MemorySourcesRegistryProps { @@ -31,6 +32,21 @@ interface MemorySourcesRegistryProps { pollIntervalMs?: number; } +interface SyncProgress { + stage: string; + detail: string | null; + percent: number | null; +} + +function parseSyncProgress(detail: string | null): number | null { + if (!detail) return null; + const match = detail.match(/^(\d+)\/(\d+)\s/); + if (!match) return null; + const current = parseInt(match[1], 10); + const total = parseInt(match[2], 10); + return total > 0 ? Math.round((current / total) * 100) : null; +} + export function MemorySourcesRegistry({ onToast, pollIntervalMs = 5000, @@ -41,6 +57,43 @@ export function MemorySourcesRegistry({ const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); const [syncingId, setSyncingId] = useState(null); + const [buildingId, setBuildingId] = useState(null); + const [syncProgress, setSyncProgress] = useState>(new Map()); + + useEffect(() => { + const handler = (e: Event) => { + const data = (e as CustomEvent).detail as { + stage?: string; + connection_id?: string; + detail?: string; + } | null; + if (!data?.connection_id) return; + const sourceId = data.connection_id; + const stage = data.stage ?? ''; + + if (stage === 'completed' || stage === 'failed') { + setSyncProgress(prev => { + const next = new Map(prev); + next.delete(sourceId); + return next; + }); + setSyncingId(prev => (prev === sourceId ? null : prev)); + return; + } + + const percent = parseSyncProgress(data.detail ?? null); + setSyncProgress(prev => { + const next = new Map(prev); + next.set(sourceId, { stage, detail: data.detail ?? null, percent }); + return next; + }); + if (stage === 'requested' || stage === 'fetching' || stage === 'ingesting') { + setSyncingId(sourceId); + } + }; + window.addEventListener('openhuman:memory-sync-stage', handler); + return () => window.removeEventListener('openhuman:memory-sync-stage', handler); + }, []); const refresh = useCallback(async () => { try { @@ -136,6 +189,31 @@ export function MemorySourcesRegistry({ [onToast, refresh, t] ); + const handleBuild = useCallback( + async (source: MemorySourceEntry) => { + const scope = sourceTreeScope(source); + if (!scope) return; + setBuildingId(source.id); + try { + const resp = await memoryTreeFlushSource(scope); + onToast?.({ + type: 'success', + title: t('memorySources.build.successTitle'), + message: `${resp.seals_fired} ${t('memorySources.build.sealsMessage')}`, + }); + } catch (err) { + onToast?.({ + type: 'error', + title: t('memorySources.build.failedTitle'), + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setBuildingId(prev => (prev === source.id ? null : prev)); + } + }, + [onToast, t] + ); + const handleAdded = useCallback( (source: MemorySourceEntry) => { setSources(prev => [...prev, source]); @@ -176,9 +254,12 @@ export function MemorySourcesRegistry({ source={source} status={statusById.get(source.id) ?? null} isSyncing={syncingId === source.id} + isBuilding={buildingId === source.id} + progress={syncProgress.get(source.id) ?? null} onToggle={handleToggle} onRemove={handleRemove} onSync={handleSync} + onBuild={handleBuild} /> ))} @@ -197,12 +278,25 @@ interface SourceRowProps { source: MemorySourceEntry; status: SourceStatus | null; isSyncing: boolean; + isBuilding: boolean; + progress: SyncProgress | null; onToggle: (source: MemorySourceEntry) => void; onRemove: (source: MemorySourceEntry) => void; onSync: (source: MemorySourceEntry) => void; + onBuild: (source: MemorySourceEntry) => void; } -function SourceRow({ source, status, isSyncing, onToggle, onRemove, onSync }: SourceRowProps) { +function SourceRow({ + source, + status, + isSyncing, + isBuilding, + progress, + onToggle, + onRemove, + onSync, + onBuild, +}: SourceRowProps) { const { t } = useT(); const icon = SOURCE_KIND_ICONS[source.kind] ?? '📄'; const kindLabel = t(SOURCE_KIND_LABEL_KEYS[source.kind] ?? source.kind); @@ -234,7 +328,32 @@ function SourceRow({ source, status, isSyncing, onToggle, onRemove, onSync }: So {detail}

)} - {status && (status.chunks_synced > 0 || status.chunks_pending > 0) && ( + {progress && ( +
+
+ {progress.stage} + {progress.percent !== null && ( + + {progress.percent}% + + )} + {progress.detail && ( + + {progress.detail} + + )} +
+
+
+
+
+ )} + {!progress && status && (status.chunks_synced > 0 || status.chunks_pending > 0) && (
{status.chunks_synced.toLocaleString()} {t('sync.chunks')} @@ -266,6 +385,21 @@ function SourceRow({ source, status, isSyncing, onToggle, onRemove, onSync }: So {isSyncing ? : } {isSyncing ? t('sync.syncing') : t('sync.sync')} +
); } diff --git a/app/src/components/intelligence/PixiGraph.tsx b/app/src/components/intelligence/PixiGraph.tsx index 958a79032f..c05c4cde69 100644 --- a/app/src/components/intelligence/PixiGraph.tsx +++ b/app/src/components/intelligence/PixiGraph.tsx @@ -47,11 +47,24 @@ export function PixiGraph({ onErrorRef.current = onError; darkRef.current = dark; - // (Re)mount the renderer whenever the graph data or mode changes. + // Mount the renderer once; update in-place when graph data changes. + const mountedModeRef = useRef(null); + useEffect(() => { const host = hostRef.current; if (!host) return; + + // Mode change requires full remount (different edge semantics). + if (handleRef.current && mountedModeRef.current === mode) { + const { simNodes, links } = buildGraph(nodes, edges, mode); + handleRef.current.updateGraph(simNodes, links); + return; + } + + // First mount or mode flip — full init. let cancelled = false; + handleRef.current?.destroy(); + handleRef.current = null; const { simNodes, links } = buildGraph(nodes, edges, mode); const pending = mountPixiGraph(host, { simNodes, @@ -66,11 +79,10 @@ export function PixiGraph({ return null; } handleRef.current = handle; + mountedModeRef.current = mode; return handle; }) .catch(err => { - // Runtime WebGL failure (driver / lost context) even though - // supportsWebGL() was true — let the parent fall back to SVG. console.error('[memory-graph] Pixi init failed; falling back to SVG', err); if (!cancelled) onErrorRef.current?.(); return null; @@ -78,8 +90,10 @@ export function PixiGraph({ return () => { cancelled = true; handleRef.current = null; + mountedModeRef.current = null; void pending.then(handle => handle?.destroy()); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodes, edges, mode]); useEffect(() => { diff --git a/app/src/components/intelligence/SyncAuditPanel.tsx b/app/src/components/intelligence/SyncAuditPanel.tsx new file mode 100644 index 0000000000..852a7b90cc --- /dev/null +++ b/app/src/components/intelligence/SyncAuditPanel.tsx @@ -0,0 +1,168 @@ +/** + * Sync audit history panel — shows when syncs happened, tokens consumed, + * cost, and duration. Fetches from `openhuman.memory_sources_sync_audit_log`. + */ +import { useEffect, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { memorySyncAuditLog, type SyncAuditEntry } from '../../utils/tauriCommands'; + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const secs = ms / 1000; + if (secs < 60) return `${secs.toFixed(1)}s`; + const mins = Math.floor(secs / 60); + const remSecs = Math.round(secs % 60); + return `${mins}m ${remSecs}s`; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function scopeLabel(scope: string): string { + if (scope.startsWith('github:')) { + return `GitHub · ${scope.slice(7)}`; + } + if (scope.startsWith('gmail:')) { + return `Gmail · ${scope.slice(6).replace(/-at-/g, '@').replace(/-dot-/g, '.')}`; + } + if (scope.startsWith('rebuild:')) { + return `Rebuild · ${scope.slice(8)}`; + } + return scope; +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function SyncAuditPanel() { + const { t } = useT(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const data = await memorySyncAuditLog(); + if (!cancelled) setEntries(data); + } catch (err) { + console.error('[sync-audit] fetch failed', err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + if (loading) { + return ( +
+ {t('common.loading', 'Loading...')} +
+ ); + } + + if (entries.length === 0) { + return ( +
+ {t('sync.noAuditEntries', 'No sync runs recorded yet.')} +
+ ); + } + + const totalCost = entries.reduce((s, e) => s + e.estimated_cost_usd, 0); + const totalInput = entries.reduce((s, e) => s + e.input_tokens, 0); + const totalOutput = entries.reduce((s, e) => s + e.output_tokens, 0); + + return ( +
+
+ + {entries.length} {t('sync.runs', 'sync runs')} + + · + + {formatTokens(totalInput)} in / {formatTokens(totalOutput)} out + + · + + ${totalCost.toFixed(4)} {t('sync.totalCost', 'total')} + +
+
+ + + + + + + + + + + + + + {entries.map((e, i) => ( + + + + + + + + + + ))} + +
{t('sync.when', 'When')}{t('sync.source', 'Source')}{t('sync.items', 'Items')}{t('sync.tokens', 'Tokens')}{t('sync.cost', 'Cost')} + {t('sync.duration', 'Duration')} +
+ {timeAgo(e.timestamp)} + + {scopeLabel(e.scope)} + + {e.items_fetched} + + {formatTokens(e.input_tokens + e.output_tokens)} + + ${e.estimated_cost_usd.toFixed(4)} + + {formatDuration(e.duration_ms)} + + {e.success ? ( + + ✓ + + ) : ( + + ✗ + + )} +
+
+
+ ); +} diff --git a/app/src/components/intelligence/Toast.tsx b/app/src/components/intelligence/Toast.tsx index b506474a11..0b5e755883 100644 --- a/app/src/components/intelligence/Toast.tsx +++ b/app/src/components/intelligence/Toast.tsx @@ -41,10 +41,17 @@ const TOAST_ICONS = { }; const TOAST_STYLES = { - success: 'bg-sage-500 text-white', - error: 'bg-coral-500 text-white', - warning: 'bg-amber-500 text-white', - info: 'bg-primary-500 text-white', + success: 'bg-neutral-0 border-sage-500 text-neutral-900', + error: 'bg-neutral-0 border-coral-500 text-neutral-900', + warning: 'bg-neutral-0 border-amber-500 text-neutral-900', + info: 'bg-neutral-0 border-primary-500 text-neutral-900', +}; + +const TOAST_ICON_STYLES = { + success: 'text-sage-600', + error: 'text-coral-500', + warning: 'text-amber-600', + info: 'text-primary-500', }; export function Toast({ notification, onRemove }: ToastProps) { @@ -76,6 +83,7 @@ export function Toast({ notification, onRemove }: ToastProps) { const icon = TOAST_ICONS[notification.type]; const styles = TOAST_STYLES[notification.type]; + const iconStyle = TOAST_ICON_STYLES[notification.type]; return (
{/* Icon */} -
{icon}
+
{icon}
{/* Content */}

{notification.title}

{notification.message && ( -

{notification.message}

+

{notification.message}

)}
@@ -113,7 +121,7 @@ export function Toast({ notification, onRemove }: ToastProps) { {/* Close button */}