feat(memory-sources): default sync sources on with conservative caps, per-source settings & All In (#3304)

This commit is contained in:
Cyrus Gray
2026-06-04 10:34:22 -04:00
committed by GitHub
parent 81e527ee2d
commit 0d9002fcfc
52 changed files with 3405 additions and 154 deletions
@@ -8,10 +8,11 @@
* row dispatches `openhuman.memory_sources_sync` which runs in the
* background and emits MemorySyncStageChanged events.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import {
applyAllIn,
type FreshnessLabel,
listMemorySources,
type MemorySourceEntry,
@@ -23,9 +24,14 @@ import {
syncMemorySource,
updateMemorySource,
} from '../../services/memorySourcesService';
import type { ToastNotification } from '../../types/intelligence';
import type {
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../../types/intelligence';
import { memoryTreeFlushSource } from '../../utils/tauriCommands/memoryTree';
import { AddMemorySourceDialog } from './AddMemorySourceDialog';
import { ConfirmationModal } from './ConfirmationModal';
import { SourceSettingsPanel } from './SourceSettingsPanel';
interface MemorySourcesRegistryProps {
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
@@ -59,6 +65,10 @@ export function MemorySourcesRegistry({
const [syncingId, setSyncingId] = useState<string | null>(null);
const [buildingId, setBuildingId] = useState<string | null>(null);
const [syncProgress, setSyncProgress] = useState<Map<string, SyncProgress>>(new Map());
const [allInModalOpen, setAllInModalOpen] = useState(false);
const [applyingAllIn, setApplyingAllIn] = useState(false);
const allInInFlightRef = useRef(false);
const [expandedSettingsId, setExpandedSettingsId] = useState<string | null>(null);
useEffect(() => {
const handler = (e: Event) => {
@@ -223,23 +233,84 @@ export function MemorySourcesRegistry({
[onToast, refresh, t]
);
const handleConfirmAllIn = useCallback(async () => {
if (allInInFlightRef.current) return;
allInInFlightRef.current = true;
setApplyingAllIn(true);
try {
const result = await applyAllIn();
setSources(result.sources);
onToast?.({ type: 'success', title: t('memorySources.allIn.success') });
} catch (err) {
onToast?.({
type: 'error',
title: t('memorySources.allIn.failed'),
message: err instanceof Error ? err.message : String(err),
});
} finally {
allInInFlightRef.current = false;
setApplyingAllIn(false);
setAllInModalOpen(false);
}
}, [onToast, t]);
const handleSettingsSaved = useCallback((updated: MemorySourceEntry) => {
setSources(prev => prev.map(s => (s.id === updated.id ? updated : s)));
}, []);
const handleToggleSettings = useCallback((sourceId: string) => {
setExpandedSettingsId(prev => (prev === sourceId ? null : sourceId));
}, []);
const allInModal: ConfirmationModalType = {
isOpen: allInModalOpen,
title: t('memorySources.allIn.title'),
message: t('memorySources.allIn.message'),
confirmText: t('memorySources.allIn.confirm'),
cancelText: t('memorySources.allIn.cancel'),
destructive: false,
onConfirm: () => {
void handleConfirmAllIn();
},
onCancel: () => {
setAllInModalOpen(false);
},
};
return (
<section
className="rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"
data-testid="memory-sources">
<header className="mb-3 flex items-center justify-between">
<header className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('memorySources.title')}
</h3>
<button
type="button"
onClick={() => setDialogOpen(true)}
className="inline-flex items-center gap-1 rounded-md bg-primary-500 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-200">
<PlusIcon />
{t('memorySources.addSource')}
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setAllInModalOpen(true)}
disabled={applyingAllIn}
data-testid="all-in-button"
className="inline-flex items-center gap-1 rounded-md border border-primary-300
bg-white px-3 py-1.5 text-xs font-semibold text-primary-600
shadow-sm transition-colors hover:bg-primary-50
disabled:cursor-not-allowed disabled:opacity-50
dark:border-primary-500/30 dark:bg-neutral-900 dark:text-primary-400
dark:hover:bg-primary-500/10
focus:outline-none focus:ring-2 focus:ring-primary-200">
<AllInIcon />
{t('memorySources.allIn.button')}
</button>
<button
type="button"
onClick={() => setDialogOpen(true)}
className="inline-flex items-center gap-1 rounded-md bg-primary-500 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-200">
<PlusIcon />
{t('memorySources.addSource')}
</button>
</div>
</header>
{loading ? (
@@ -256,10 +327,14 @@ export function MemorySourcesRegistry({
isSyncing={syncingId === source.id}
isBuilding={buildingId === source.id}
progress={syncProgress.get(source.id) ?? null}
settingsExpanded={expandedSettingsId === source.id}
onToggle={handleToggle}
onRemove={handleRemove}
onSync={handleSync}
onBuild={handleBuild}
onToggleSettings={handleToggleSettings}
onSettingsSaved={handleSettingsSaved}
onToast={onToast}
/>
))}
</ul>
@@ -270,6 +345,10 @@ export function MemorySourcesRegistry({
onClose={() => setDialogOpen(false)}
onAdded={handleAdded}
/>
{allInModalOpen && (
<ConfirmationModal modal={allInModal} onClose={() => setAllInModalOpen(false)} />
)}
</section>
);
}
@@ -280,10 +359,14 @@ interface SourceRowProps {
isSyncing: boolean;
isBuilding: boolean;
progress: SyncProgress | null;
settingsExpanded: boolean;
onToggle: (source: MemorySourceEntry) => void;
onRemove: (source: MemorySourceEntry) => void;
onSync: (source: MemorySourceEntry) => void;
onBuild: (source: MemorySourceEntry) => void;
onToggleSettings: (sourceId: string) => void;
onSettingsSaved: (updated: MemorySourceEntry) => void;
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
}
function SourceRow({
@@ -292,10 +375,14 @@ function SourceRow({
isSyncing,
isBuilding,
progress,
settingsExpanded,
onToggle,
onRemove,
onSync,
onBuild,
onToggleSettings,
onSettingsSaved,
onToast,
}: SourceRowProps) {
const { t } = useT();
const icon = SOURCE_KIND_ICONS[source.kind] ?? '📄';
@@ -304,125 +391,146 @@ function SourceRow({
const lastSync = status ? relativeTimestamp(status.last_chunk_at_ms, t) : null;
return (
<li
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between"
data-testid={`memory-source-row-${source.kind}`}>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base">{icon}</span>
<span
className={`truncate text-sm font-medium ${
source.enabled
? 'text-stone-900 dark:text-neutral-100'
: 'text-stone-400 line-through dark:text-neutral-500'
}`}>
{source.label}
</span>
<span className="rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] font-medium text-stone-500 dark:bg-neutral-800 dark:text-neutral-400">
{kindLabel}
</span>
{status && status.chunks_synced > 0 && <FreshnessPill freshness={status.freshness} />}
</div>
{detail && (
<p className="mt-0.5 truncate pl-7 text-xs text-stone-400 dark:text-neutral-500">
{detail}
</p>
)}
{progress && (
<div className="mt-2 pl-7">
<div className="flex items-center gap-2 text-xs text-stone-500 dark:text-neutral-400">
<span className="capitalize">{progress.stage}</span>
{progress.percent !== null && (
<span className="font-medium text-primary-600 dark:text-primary-400">
{progress.percent}%
</span>
)}
{progress.detail && (
<span className="truncate text-stone-400 dark:text-neutral-500">
{progress.detail}
</span>
)}
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-stone-200 dark:bg-neutral-700">
<div
className="h-full rounded-full bg-primary-500 transition-all duration-300"
style={{
width: `${progress.percent ?? (progress.stage === 'fetching' ? 10 : 5)}%`,
}}
/>
</div>
</div>
)}
{!progress && status && (status.chunks_synced > 0 || status.chunks_pending > 0) && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 pl-7 text-xs text-stone-500 dark:text-neutral-400">
<span>
{status.chunks_synced.toLocaleString()} {t('sync.chunks')}
<li className="flex flex-col gap-2 py-3" data-testid={`memory-source-row-${source.kind}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base">{icon}</span>
<span
className={`truncate text-sm font-medium ${
source.enabled
? 'text-stone-900 dark:text-neutral-100'
: 'text-stone-400 line-through dark:text-neutral-500'
}`}>
{source.label}
</span>
{lastSync && (
<span>
{t('sync.lastChunk')} {lastSync}
</span>
)}
{status.chunks_pending > 0 && (
<span>
{status.chunks_pending.toLocaleString()} {t('sync.pending')}
</span>
)}
<span className="rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] font-medium text-stone-500 dark:bg-neutral-800 dark:text-neutral-400">
{kindLabel}
</span>
{status && status.chunks_synced > 0 && <FreshnessPill freshness={status.freshness} />}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => onSync(source)}
disabled={!source.enabled || isSyncing}
title={t('sync.sync')}
data-testid={`memory-source-sync-${source.toolkit ?? source.kind}`}
className="inline-flex items-center gap-1 rounded-md bg-primary-500 px-3 py-1.5
{detail && (
<p className="mt-0.5 truncate pl-7 text-xs text-stone-400 dark:text-neutral-500">
{detail}
</p>
)}
{progress && (
<div className="mt-2 pl-7">
<div className="flex items-center gap-2 text-xs text-stone-500 dark:text-neutral-400">
<span className="capitalize">{progress.stage}</span>
{progress.percent !== null && (
<span className="font-medium text-primary-600 dark:text-primary-400">
{progress.percent}%
</span>
)}
{progress.detail && (
<span className="truncate text-stone-400 dark:text-neutral-500">
{progress.detail}
</span>
)}
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-stone-200 dark:bg-neutral-700">
<div
className="h-full rounded-full bg-primary-500 transition-all duration-300"
style={{
width: `${progress.percent ?? (progress.stage === 'fetching' ? 10 : 5)}%`,
}}
/>
</div>
</div>
)}
{!progress && status && (status.chunks_synced > 0 || status.chunks_pending > 0) && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 pl-7 text-xs text-stone-500 dark:text-neutral-400">
<span>
{status.chunks_synced.toLocaleString()} {t('sync.chunks')}
</span>
{lastSync && (
<span>
{t('sync.lastChunk')} {lastSync}
</span>
)}
{status.chunks_pending > 0 && (
<span>
{status.chunks_pending.toLocaleString()} {t('sync.pending')}
</span>
)}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => onToggleSettings(source.id)}
title={t('memorySources.settings.button')}
data-testid={`memory-source-settings-${source.id}`}
aria-expanded={settingsExpanded}
className={`rounded p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-200 ${
settingsExpanded
? 'bg-primary-100 text-primary-600 dark:bg-primary-500/20 dark:text-primary-400'
: 'text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300'
}`}>
<GearIcon />
</button>
<button
type="button"
onClick={() => onSync(source)}
disabled={!source.enabled || isSyncing}
title={t('sync.sync')}
data-testid={`memory-source-sync-${source.toolkit ?? source.kind}`}
className="inline-flex items-center gap-1 rounded-md bg-primary-500 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
{isSyncing ? <Spinner /> : <SyncIcon />}
{isSyncing ? t('sync.syncing') : t('sync.sync')}
</button>
<button
type="button"
onClick={() => onBuild(source)}
disabled={!source.enabled || isBuilding || isSyncing}
title={t('memorySources.build.title')}
className="inline-flex items-center gap-1 rounded-md border border-primary-300
{isSyncing ? <Spinner /> : <SyncIcon />}
{isSyncing ? t('sync.syncing') : t('sync.sync')}
</button>
<button
type="button"
onClick={() => onBuild(source)}
disabled={!source.enabled || isBuilding || isSyncing}
title={t('memorySources.build.title')}
className="inline-flex items-center gap-1 rounded-md border border-primary-300
bg-white px-3 py-1.5 text-xs font-semibold text-primary-600
shadow-sm transition-colors hover:bg-primary-50
disabled:cursor-not-allowed disabled:opacity-50
dark:border-primary-500/30 dark:bg-neutral-900 dark:text-primary-400
dark:hover:bg-primary-500/10
focus:outline-none focus:ring-2 focus:ring-primary-200">
{isBuilding ? <Spinner /> : <BuildIcon />}
{isBuilding ? t('memorySources.build.building') : t('memorySources.build.title')}
</button>
<button
type="button"
onClick={() => onToggle(source)}
title={source.enabled ? t('memorySources.disable') : t('memorySources.enable')}
className={`relative h-5 w-9 rounded-full transition-colors ${
source.enabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-600'
}`}>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
source.enabled ? 'left-[18px]' : 'left-0.5'
}`}
/>
</button>
<button
type="button"
onClick={() => onRemove(source)}
title={t('memorySources.remove')}
className="rounded p-1 text-stone-400 transition-colors hover:bg-coral-50
{isBuilding ? <Spinner /> : <BuildIcon />}
{isBuilding ? t('memorySources.build.building') : t('memorySources.build.title')}
</button>
<button
type="button"
onClick={() => onToggle(source)}
title={source.enabled ? t('memorySources.disable') : t('memorySources.enable')}
className={`relative h-5 w-9 rounded-full transition-colors ${
source.enabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-600'
}`}>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
source.enabled ? 'left-[18px]' : 'left-0.5'
}`}
/>
</button>
<button
type="button"
onClick={() => onRemove(source)}
title={t('memorySources.remove')}
className="rounded p-1 text-stone-400 transition-colors hover:bg-coral-50
hover:text-coral-600 dark:text-neutral-500 dark:hover:bg-coral-500/10
dark:hover:text-coral-400">
<TrashIcon />
</button>
<TrashIcon />
</button>
</div>
</div>
{settingsExpanded && (
<SourceSettingsPanel
source={source}
syncedCount={status?.chunks_synced}
onSaved={onSettingsSaved}
onToast={onToast}
/>
)}
</li>
);
}
@@ -573,3 +681,38 @@ function Spinner() {
</svg>
);
}
function GearIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
);
}
function AllInIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
);
}
@@ -0,0 +1,224 @@
/**
* Expandable per-source sync-settings panel.
*
* Rendered inline below a SourceRow when the gear icon is clicked.
* Shows only the limit fields relevant to the source's kind, seeded from
* the current entry values. Empty input = omit the field on save (meaning
* "use backend default / unlimited").
*
* On Save calls `updateMemorySource` and notifies the parent via
* `onSaved` with the updated entry.
*/
import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import {
type MemorySourceEntry,
type SourceKind,
updateMemorySource,
} from '../../services/memorySourcesService';
// Which limit fields are relevant per kind. Order determines display order.
// Only caps that are actually enforced at sync time are surfaced — the
// per-sync token/cost budgets are not yet wired into the sync loop, so they
// are intentionally omitted here.
const KIND_FIELDS: Record<SourceKind, Array<keyof LimitFields>> = {
composio: ['sync_depth_days', 'max_items'],
github_repo: ['max_prs', 'max_issues', 'max_commits', 'sync_depth_days'],
rss_feed: ['max_items', 'sync_depth_days'],
twitter_query: ['since_days'],
web_page: ['sync_depth_days'],
folder: ['sync_depth_days'],
};
// i18n key for each field label
const FIELD_LABEL_KEYS: Record<keyof LimitFields, string> = {
max_prs: 'memorySources.settings.maxPrs',
max_issues: 'memorySources.settings.maxIssues',
max_commits: 'memorySources.settings.maxCommits',
max_items: 'memorySources.settings.maxItems',
since_days: 'memorySources.settings.sinceDays',
sync_depth_days: 'memorySources.settings.syncDepthDays',
};
type LimitFields = Pick<
MemorySourceEntry,
'max_prs' | 'max_issues' | 'max_commits' | 'max_items' | 'since_days' | 'sync_depth_days'
>;
// Item-count caps where a "Maxed" badge is meaningful (synced count vs cap).
// Time-window (sync_depth_days/since_days) and budget (tokens/cost) caps don't
// map to a chunk count, so they never show "Maxed".
const COUNT_FIELDS = new Set<keyof LimitFields>([
'max_items',
'max_prs',
'max_issues',
'max_commits',
]);
interface SourceSettingsPanelProps {
source: MemorySourceEntry;
/** Chunks already synced for this source — drives the "Maxed" badge. */
syncedCount?: number;
onSaved: (updated: MemorySourceEntry) => void;
onToast?: (toast: { type: 'success' | 'error'; title: string; message?: string }) => void;
}
export function SourceSettingsPanel({
source,
syncedCount,
onSaved,
onToast,
}: SourceSettingsPanelProps) {
const { t } = useT();
const fields = KIND_FIELDS[source.kind] ?? [];
// Hold each field as a string so inputs can be freely edited. Seeded from the
// entry's stored cap (fetched from the backend — the single source of truth;
// the caps migration writes conservative defaults onto the entry). An empty
// field genuinely means "no cap / unlimited".
const [values, setValues] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
for (const f of fields) {
const stored = source[f as keyof MemorySourceEntry];
init[f] = stored != null ? String(stored) : '';
}
return init;
});
const [saving, setSaving] = useState(false);
const handleChange = useCallback((field: string, value: string) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const patch: Partial<LimitFields> = {};
for (const f of fields) {
const raw = values[f];
if (raw !== '' && raw !== undefined) {
const parsed = Number(raw);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
onToast?.({ type: 'error', title: t('memorySources.settings.saveFailed') });
return;
}
(patch as Record<string, number>)[f] = parsed;
}
// Empty string → omit from patch (backend treats absence as "default")
}
const updated = await updateMemorySource(source.id, patch);
onSaved(updated);
onToast?.({ type: 'success', title: t('memorySources.settings.saved') });
} catch (err) {
onToast?.({
type: 'error',
title: t('memorySources.settings.saveFailed'),
message: err instanceof Error ? err.message : String(err),
});
} finally {
setSaving(false);
}
}, [fields, source.id, values, onSaved, onToast, t]);
if (fields.length === 0) return null;
// Display name for the tooltip — the toolkit slug (title-cased) for Composio
// sources, else the source label.
const toolkitName = source.toolkit
? source.toolkit.charAt(0).toUpperCase() + source.toolkit.slice(1)
: source.label;
const unlimitedTooltip = t('memorySources.settings.unlimitedTooltip').replace(
'{toolkit}',
toolkitName
);
return (
<div
className="mt-2 ml-7 rounded-lg border border-stone-200 bg-stone-50 p-3 dark:border-neutral-700 dark:bg-neutral-800/60"
data-testid={`source-settings-panel-${source.id}`}>
<p className="mb-2 text-xs font-semibold text-stone-600 dark:text-neutral-300">
{t('memorySources.settings.title')}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.map(field => {
const cap = Number(values[field]);
const isUnlimited = (values[field] ?? '') === '';
const isMaxed =
COUNT_FIELDS.has(field) &&
!isUnlimited &&
Number.isFinite(cap) &&
typeof syncedCount === 'number' &&
syncedCount >= cap;
return (
<div key={field}>
<label
htmlFor={`src-setting-${source.id}-${field}`}
className="mb-0.5 flex items-center gap-1.5 text-xs font-medium text-stone-600 dark:text-neutral-400">
{t(FIELD_LABEL_KEYS[field])}
{isMaxed && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
{t('memorySources.settings.maxed')}
</span>
)}
{isUnlimited && (
<span
className="inline-flex cursor-help text-stone-400 dark:text-neutral-500"
title={unlimitedTooltip}
aria-label={unlimitedTooltip}>
<InfoIcon />
</span>
)}
</label>
<input
id={`src-setting-${source.id}-${field}`}
type="number"
min={0}
step={1}
value={values[field] ?? ''}
onChange={e => handleChange(field, e.target.value)}
placeholder={t('memorySources.settings.unlimited')}
className="w-full rounded-md border border-stone-200 bg-white px-2.5 py-1.5 text-xs font-mono
text-stone-800 placeholder:text-stone-400
dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-200
dark:placeholder:text-neutral-500
focus:outline-none focus:ring-2 focus:ring-primary-200"
/>
</div>
);
})}
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
{saving ? t('memorySources.settings.saving') : t('memorySources.settings.save')}
</button>
</div>
</div>
);
}
function InfoIcon() {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
);
}
@@ -0,0 +1,322 @@
/**
* Unit tests for MemorySourcesRegistry — All In button, gear/settings panel,
* per-kind field visibility, Save, and existing toggle behaviour.
*/
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as service from '../../../services/memorySourcesService';
import type { MemorySourceEntry } from '../../../services/memorySourcesService';
import { renderWithProviders } from '../../../test/test-utils';
import { MemorySourcesRegistry } from '../MemorySourcesRegistry';
// Mock the entire service so we don't hit RPC
vi.mock('../../../services/memorySourcesService', async () => {
const actual = await vi.importActual<typeof import('../../../services/memorySourcesService')>(
'../../../services/memorySourcesService'
);
return {
...actual,
listMemorySources: vi.fn(),
memorySourcesStatusList: vi.fn(),
updateMemorySource: vi.fn(),
removeMemorySource: vi.fn(),
syncMemorySource: vi.fn(),
applyAllIn: vi.fn(),
};
});
// Mock tauriCommands/memoryTree — not needed in these tests
vi.mock('../../../utils/tauriCommands/memoryTree', () => ({
memoryTreeFlushSource: vi.fn().mockResolvedValue({ seals_fired: 0 }),
}));
const mockedList = vi.mocked(service.listMemorySources);
const mockedStatus = vi.mocked(service.memorySourcesStatusList);
const mockedUpdate = vi.mocked(service.updateMemorySource);
const mockedApplyAllIn = vi.mocked(service.applyAllIn);
function makeSource(overrides: Partial<MemorySourceEntry> = {}): MemorySourceEntry {
return {
id: 'src_1',
kind: 'github_repo',
label: 'My Repo',
enabled: true,
url: 'https://github.com/org/repo',
...overrides,
};
}
function setup(sources: MemorySourceEntry[] = [makeSource()]) {
mockedList.mockResolvedValue(sources);
mockedStatus.mockResolvedValue([]);
const onToast = vi.fn();
const result = renderWithProviders(
<MemorySourcesRegistry onToast={onToast} pollIntervalMs={0} />,
{}
);
return { ...result, onToast };
}
describe('MemorySourcesRegistry', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// -------------------------------------------------------------------------
// Basic render
// -------------------------------------------------------------------------
it('renders loaded sources list', async () => {
setup([makeSource({ label: 'Work Repo' })]);
await screen.findByText('Work Repo');
expect(screen.getByTestId('memory-sources')).toBeInTheDocument();
});
it('renders empty state when no sources', async () => {
mockedList.mockResolvedValue([]);
mockedStatus.mockResolvedValue([]);
renderWithProviders(<MemorySourcesRegistry pollIntervalMs={0} />);
await screen.findByText(/no memory sources/i);
});
// -------------------------------------------------------------------------
// Toggle (existing behaviour)
// -------------------------------------------------------------------------
it('toggle calls updateMemorySource and flips state', async () => {
const source = makeSource({ enabled: true });
mockedUpdate.mockResolvedValue({ ...source, enabled: false });
setup([source]);
await screen.findByText('My Repo');
const toggle = screen.getByTitle(/disable/i);
fireEvent.click(toggle);
await waitFor(() => {
expect(mockedUpdate).toHaveBeenCalledWith('src_1', { enabled: false });
});
});
// -------------------------------------------------------------------------
// All In button
// -------------------------------------------------------------------------
it('All In button is rendered in the header', async () => {
setup();
await screen.findByText('My Repo');
expect(screen.getByTestId('all-in-button')).toBeInTheDocument();
});
it('clicking All In opens a confirmation modal', async () => {
setup();
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('all-in-button'));
// The modal should appear
await screen.findByText('Go All In?');
expect(
screen.getByText(/This enables every memory source and removes all sync limits/i)
).toBeInTheDocument();
});
it('cancelling All In modal closes it without calling applyAllIn', async () => {
setup();
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('all-in-button'));
await screen.findByText('Go All In?');
// Click the No / cancel button
fireEvent.click(screen.getByText('No'));
await waitFor(() => {
expect(screen.queryByText('Go All In?')).not.toBeInTheDocument();
});
expect(mockedApplyAllIn).not.toHaveBeenCalled();
});
it('confirming All In calls applyAllIn, updates sources, and shows success toast', async () => {
const updatedSrc = makeSource({ id: 'src_2', label: 'New Repo', enabled: true });
mockedApplyAllIn.mockResolvedValue({ sources: [updatedSrc], sync_triggered: 1 });
const { onToast } = setup();
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('all-in-button'));
await screen.findByText('Go All In?');
fireEvent.click(screen.getByText('Yes'));
await waitFor(() => {
expect(mockedApplyAllIn).toHaveBeenCalledOnce();
});
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
// Modal should close
await waitFor(() => {
expect(screen.queryByText('Go All In?')).not.toBeInTheDocument();
});
});
it('All In failure shows error toast', async () => {
mockedApplyAllIn.mockRejectedValue(new Error('RPC error'));
const { onToast } = setup();
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('all-in-button'));
await screen.findByText('Go All In?');
fireEvent.click(screen.getByText('Yes'));
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
});
});
// -------------------------------------------------------------------------
// Gear / settings panel — toggling
// -------------------------------------------------------------------------
it('gear button renders for each source row', async () => {
setup([makeSource({ id: 'src_1' }), makeSource({ id: 'src_2', label: 'Second' })]);
await screen.findByText('My Repo');
expect(screen.getByTestId('memory-source-settings-src_1')).toBeInTheDocument();
expect(screen.getByTestId('memory-source-settings-src_2')).toBeInTheDocument();
});
it('clicking gear expands the settings panel for that source', async () => {
setup([makeSource({ id: 'src_1', kind: 'github_repo' })]);
await screen.findByText('My Repo');
expect(screen.queryByTestId('source-settings-panel-src_1')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('memory-source-settings-src_1'));
expect(screen.getByTestId('source-settings-panel-src_1')).toBeInTheDocument();
});
it('clicking gear again collapses the settings panel', async () => {
setup([makeSource({ id: 'src_1', kind: 'github_repo' })]);
await screen.findByText('My Repo');
const gearBtn = screen.getByTestId('memory-source-settings-src_1');
fireEvent.click(gearBtn);
expect(screen.getByTestId('source-settings-panel-src_1')).toBeInTheDocument();
fireEvent.click(gearBtn);
expect(screen.queryByTestId('source-settings-panel-src_1')).not.toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Settings panel — field visibility per kind
// -------------------------------------------------------------------------
it('github_repo settings panel shows max_prs, max_issues, max_commits, sync_depth_days', async () => {
setup([makeSource({ id: 'src_1', kind: 'github_repo' })]);
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('memory-source-settings-src_1'));
const panel = screen.getByTestId('source-settings-panel-src_1');
expect(within(panel).getByLabelText(/max pull requests/i)).toBeInTheDocument();
expect(within(panel).getByLabelText(/max issues/i)).toBeInTheDocument();
expect(within(panel).getByLabelText(/max commits/i)).toBeInTheDocument();
expect(within(panel).getByLabelText(/sync depth/i)).toBeInTheDocument();
});
it('composio settings panel shows sync_depth_days and max_items but NOT max_prs', async () => {
setup([makeSource({ id: 'src_1', kind: 'composio', toolkit: 'github' })]);
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('memory-source-settings-src_1'));
const panel = screen.getByTestId('source-settings-panel-src_1');
expect(within(panel).getByLabelText(/max items/i)).toBeInTheDocument();
expect(within(panel).getByLabelText(/sync depth/i)).toBeInTheDocument();
expect(within(panel).queryByLabelText(/max pull requests/i)).not.toBeInTheDocument();
});
it('rss_feed settings panel shows max_items and sync_depth_days but NOT max_prs', async () => {
setup([makeSource({ id: 'src_1', kind: 'rss_feed', url: 'https://example.com/feed.xml' })]);
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('memory-source-settings-src_1'));
const panel = screen.getByTestId('source-settings-panel-src_1');
expect(within(panel).getByLabelText(/max items/i)).toBeInTheDocument();
expect(within(panel).getByLabelText(/sync depth/i)).toBeInTheDocument();
expect(within(panel).queryByLabelText(/max pull requests/i)).not.toBeInTheDocument();
expect(within(panel).queryByLabelText(/max commits/i)).not.toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Settings panel — Save
// -------------------------------------------------------------------------
it('Save in settings panel calls updateMemorySource with numeric patch', async () => {
const source = makeSource({ id: 'src_1', kind: 'github_repo' });
const updated = { ...source, max_prs: 50 };
mockedUpdate.mockResolvedValue(updated);
const { onToast } = setup([source]);
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('memory-source-settings-src_1'));
const panel = screen.getByTestId('source-settings-panel-src_1');
const maxPrsInput = within(panel).getByLabelText(/max pull requests/i);
fireEvent.change(maxPrsInput, { target: { value: '50' } });
const saveBtn = within(panel).getByText('Save');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockedUpdate).toHaveBeenCalledWith('src_1', expect.objectContaining({ max_prs: 50 }));
});
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
});
it('empty input is omitted from the save patch (not sent as 0)', async () => {
const source = makeSource({ id: 'src_1', kind: 'github_repo', max_prs: 10 });
mockedUpdate.mockResolvedValue(source);
setup([source]);
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('memory-source-settings-src_1'));
const panel = screen.getByTestId('source-settings-panel-src_1');
const maxPrsInput = within(panel).getByLabelText(/max pull requests/i);
// Clear the field
fireEvent.change(maxPrsInput, { target: { value: '' } });
fireEvent.click(within(panel).getByText('Save'));
await waitFor(() => {
expect(mockedUpdate).toHaveBeenCalledWith(
'src_1',
expect.not.objectContaining({ max_prs: expect.anything() })
);
});
});
it('Save failure shows error toast', async () => {
mockedUpdate.mockRejectedValue(new Error('Save failed'));
const { onToast } = setup([makeSource({ kind: 'github_repo' })]);
await screen.findByText('My Repo');
fireEvent.click(screen.getByTestId('memory-source-settings-src_1'));
const panel = screen.getByTestId('source-settings-panel-src_1');
fireEvent.click(within(panel).getByText('Save'));
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
});
});
});