mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(memory-sources): default sync sources on with conservative caps, per-source settings & All In (#3304)
This commit is contained in:
@@ -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' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user