Files
openhuman/app/src/components/intelligence/MemorySourcesRegistry.tsx
T

1226 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unified memory sources panel.
*
* Single source of truth for **what feeds memory**: folders, GitHub
* repos, RSS feeds, web pages, Twitter queries, and Composio
* integrations. Polls `openhuman.memory_sources_status_list` every 5s
* for per-source chunk counts and freshness. The Sync button on each
* row dispatches `openhuman.memory_sources_sync` which runs in the
* background and emits MemorySyncStageChanged events.
*/
import debug from 'debug';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { CoreStateContext } from '../../providers/coreStateContext';
import {
applyAllIn,
type FreshnessLabel,
listMemorySources,
type MemorySourceEntry,
memorySourcesStatusList,
removeMemorySource,
SOURCE_KIND_ICONS,
SOURCE_KIND_LABEL_KEYS,
type SourceStatus,
syncMemorySource,
updateMemorySource,
} from '../../services/memorySourcesService';
import type {
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../../types/intelligence';
import {
type MemorySyncSettings,
openhumanGetMemorySyncSettings,
openhumanUpdateMemorySyncSettings,
} from '../../utils/tauriCommands/config';
import {
memoryTreeFlushSource,
memoryTreePipelineStatus,
type MemoryTreePipelineStatus,
} from '../../utils/tauriCommands/memoryTree';
import Button from '../ui/Button';
import { AddMemorySourceDialog } from './AddMemorySourceDialog';
import { ConfirmationModal } from './ConfirmationModal';
import {
deriveSourcePipelineHealth,
pipelineIssueMessageKey,
type SourcePipelineHealth,
} from './sourcePipelineStatus';
import { SourceSettingsPanel } from './SourceSettingsPanel';
const log = debug('intelligence:memory-sync');
interface MemorySourcesRegistryProps {
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
pollIntervalMs?: number;
}
interface SyncProgress {
stage: string;
detail: string | null;
percent: number | null;
}
/**
* Terminal outcome of a sync run, shown on the row after the `completed` or
* `failed` stage event arrives (#3295). Persists until the next sync starts,
* so a no-op ("0 new items") or failed sync leaves visible confirmation
* instead of the indicator silently vanishing.
*/
interface SyncResult {
kind: 'success' | 'failed';
/** New items ingested (success only); null when the count is unknown. */
items: number | null;
/** Human-readable failure reason (failed only). */
reason: string | null;
}
/**
* Per-stage fallback percentages so the progress bar always advances even
* when no numeric "N/M" ratio is present in the detail string (RC#4, #3295).
*/
const STAGE_FALLBACK_PERCENT: Record<string, number> = {
requested: 2,
fetching: 5,
stored: 15,
queued: 25,
ingesting: 40,
completed: 100,
};
/**
* Parse a sync progress detail string into a 0100 percent.
*
* - Recognises "N/M ..." numeric patterns and returns N/M as a ratio.
* - Falls back to the per-stage baseline when no ratio is present rather
* than returning a bogus number (RC#4, issue #3295).
* - Returns `null` when both approaches are unavailable (no stage either).
*/
export function parseSyncProgress(detail: string | null, stage?: string): number | null {
// Try the numeric "N/M ..." ratio first.
if (detail) {
const match = detail.match(/^(\d+)\/(\d+)[\s/]/);
if (match) {
const current = parseInt(match[1], 10);
const total = parseInt(match[2], 10);
if (total > 0) return Math.round((current / total) * 100);
}
}
// Fall back to the per-stage baseline percentage.
if (stage && stage in STAGE_FALLBACK_PERCENT) {
return STAGE_FALLBACK_PERCENT[stage];
}
return null;
}
/**
* Parse the number of newly-ingested items from a `completed` stage detail
* string. The backend formats this as `"ingested N item(s)"`
* (`memory_sources/sync.rs`). Returns `null` when no count is present so the
* UI can fall back to a generic "synced" confirmation (#3295).
*/
export function parseIngestedCount(detail: string | null): number | null {
if (!detail) return null;
const match = detail.match(/ingested\s+(\d+)\s+item/i);
if (match) return parseInt(match[1], 10);
return null;
}
export function MemorySourcesRegistry({
onToast,
pollIntervalMs = 5000,
}: MemorySourcesRegistryProps) {
const { t } = useT();
const navigate = useNavigate();
// Read the core snapshot directly (not via the throwing `useCoreState`
// hook) so this component still renders in unit tests that mount it
// without a CoreStateProvider — there `ctx` is null and `isAuthenticated`
// stays a stable `false`, so the load effect behaves exactly as before.
const coreState = useContext(CoreStateContext);
const isAuthenticated = coreState?.snapshot.auth.isAuthenticated ?? false;
const [sources, setSources] = useState<MemorySourceEntry[]>([]);
const [statuses, setStatuses] = useState<SourceStatus[]>([]);
// Global downstream pipeline health (GH-4690) — the same snapshot the
// Brain > Memory > Sync panel renders. Folded into each row so a source that
// ingested but failed embeddings/extraction/tree-build shows a warning rather
// than a clean synced badge. `null` until the first poll resolves.
const [pipeline, setPipeline] = useState<MemoryTreePipelineStatus | null>(null);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
// RC#1 (#3295): use a Set so multiple sources can show "syncing" concurrently.
// Set state is always replaced with a new Set to trigger re-renders.
const [syncingIds, setSyncingIds] = useState<Set<string>>(new Set());
const [buildingId, setBuildingId] = useState<string | null>(null);
const [syncProgress, setSyncProgress] = useState<Map<string, SyncProgress>>(new Map());
// Terminal per-source result (success/failure) shown after a sync ends (#3295).
const [syncResults, setSyncResults] = useState<Map<string, SyncResult>>(new Map());
const [allInModalOpen, setAllInModalOpen] = useState(false);
const [applyingAllIn, setApplyingAllIn] = useState(false);
const allInInFlightRef = useRef(false);
const [expandedSettingsId, setExpandedSettingsId] = useState<string | null>(null);
// Refs let the (intentionally dep-free) sync-stage listener fire accurate
// toasts on the *terminal* event without re-subscribing on every render or
// 5s poll. The handler must read the latest onToast/sources/t (#3295).
const onToastRef = useRef(onToast);
const sourcesRef = useRef(sources);
const tRef = useRef(t);
useEffect(() => {
onToastRef.current = onToast;
sourcesRef.current = sources;
tRef.current = t;
});
useEffect(() => {
const handler = (e: Event) => {
const data = (e as CustomEvent).detail as {
stage?: string;
/** Originating memory-source id (RC#2, #3295). Preferred over connection_id. */
source_id?: string | null;
/** Legacy: document/connection id. Still present for backward compat. */
connection_id?: string | null;
detail?: string;
} | null;
// RC#2 (#3295): prefer source_id when present; fall back to connection_id for
// backward compat with older core versions that don't emit source_id yet.
const rowId = data?.source_id ?? data?.connection_id;
if (!rowId) return;
const stage = data?.stage ?? '';
console.debug(
`[ui-flow][memory-sync] stage=${stage} rowId=${rowId} source_id=${data?.source_id ?? 'absent'} connection_id=${data?.connection_id ?? 'absent'}`
);
if (stage === 'completed' || stage === 'failed') {
// Clear the live progress bar + syncing flag for this row.
setSyncProgress(prev => {
const next = new Map(prev);
next.delete(rowId);
return next;
});
// RC#1: immutable Set update — remove just this source, keep others syncing.
setSyncingIds(prev => {
const next = new Set(prev);
next.delete(rowId);
return next;
});
const tt = tRef.current;
const label = sourcesRef.current.find(s => s.id === rowId)?.label ?? rowId;
if (stage === 'completed') {
// Success: record + toast the item count parsed from the detail
// ("ingested N item(s)"). 0 new items → "up to date" (#3295).
const items = parseIngestedCount(data?.detail ?? null);
setSyncResults(prev => {
const next = new Map(prev);
next.set(rowId, { kind: 'success', items, reason: null });
return next;
});
onToastRef.current?.({
type: 'success',
title: `${tt('memorySources.sync.completeTitle')} ${label}`,
message:
items && items > 0
? `${items} ${tt('memorySources.sync.itemsSynced')}`
: tt('memorySources.sync.upToDate'),
});
} else {
// Failure: surface the reason on the row + a toast. The core already
// reported internal bugs to Sentry via report_error_or_expected.
const reason = data?.detail ?? null;
setSyncResults(prev => {
const next = new Map(prev);
next.set(rowId, { kind: 'failed', items: null, reason });
return next;
});
onToastRef.current?.({
type: 'error',
title: `${tt('memorySources.sync.failedLabel')} · ${label}`,
message: reason ?? tt('memorySources.sync.failedLabel'),
});
}
return;
}
// Non-terminal stage: a sync is genuinely in progress. Drop any stale
// terminal result for this row so the live bar replaces the old chip.
setSyncResults(prev => {
if (!prev.has(rowId)) return prev;
const next = new Map(prev);
next.delete(rowId);
return next;
});
const percent = parseSyncProgress(data?.detail ?? null, stage);
setSyncProgress(prev => {
const next = new Map(prev);
next.set(rowId, { stage, detail: data?.detail ?? null, percent });
return next;
});
// RC#1: ADD this source id to the set (immutable update).
if (
stage === 'requested' ||
stage === 'fetching' ||
stage === 'stored' ||
stage === 'queued' ||
stage === 'ingesting'
) {
setSyncingIds(prev => {
if (prev.has(rowId)) return prev; // no change — avoid re-render
const next = new Set(prev);
next.add(rowId);
return next;
});
}
};
window.addEventListener('openhuman:memory-sync-stage', handler);
return () => window.removeEventListener('openhuman:memory-sync-stage', handler);
}, []);
const refresh = useCallback(async () => {
try {
const [list, stats, health] = await Promise.all([
listMemorySources().catch(err => {
console.warn('[ui-flow][memory-sources] list failed', err);
return [] as MemorySourceEntry[];
}),
memorySourcesStatusList().catch(err => {
console.warn('[ui-flow][memory-sources] status_list failed', err);
return [] as SourceStatus[];
}),
// GH-4690: downstream pipeline health. Best-effort — a failure here
// must never hide the source list, so we fall back to `null` (rows then
// rely on the precise per-source pending-chunk signal alone).
memoryTreePipelineStatus().catch(err => {
console.warn('[ui-flow][memory-sources] pipeline_status failed', err);
return null;
}),
]);
setSources(list);
setStatuses(stats);
setPipeline(health);
// RC#5 (#3295): The 5s poll is the safety net for missed completed/failed events.
// If a source is in syncingIds but the poll shows it's no longer active (no
// in-progress status indicator from the server), we clear it here. In practice
// the event stream covers this; on remount the state rehydrates within ~5s via poll.
// No new RPC needed — reconciliation is best-effort and relies on the existing poll.
} finally {
setLoading(false);
}
}, []);
// Load on mount AND whenever the session transitions to authenticated.
// After a page reload the registry can mount (e.g. via a persisted
// `?tab=memory` deep link) *before* CoreStateProvider has restored the
// session, so the initial fetch runs against a not-yet-ready core and
// surfaces nothing. Re-running when `isAuthenticated` flips true picks up
// sources immediately instead of waiting for the next 5s poll — which
// under CI load was racing the E2E visibility timeout (#3449).
useEffect(() => {
void refresh();
}, [refresh, isAuthenticated]);
useEffect(() => {
if (!pollIntervalMs) return undefined;
const id = setInterval(() => {
void refresh();
}, pollIntervalMs);
return () => clearInterval(id);
}, [pollIntervalMs, refresh]);
const statusById = useMemo(() => {
const m = new Map<string, SourceStatus>();
for (const s of statuses) m.set(s.source_id, s);
return m;
}, [statuses]);
// Newest chunk timestamp across every source — the "Last synced …" anchor
// for the global schedule header. Derived from persisted chunk data, so it
// survives restarts.
const overallLastSyncMs = useMemo(() => {
let newest: number | null = null;
for (const s of statuses) {
if (s.last_chunk_at_ms != null && (newest === null || s.last_chunk_at_ms > newest)) {
newest = s.last_chunk_at_ms;
}
}
return newest;
}, [statuses]);
const handleToggle = useCallback(
async (source: MemorySourceEntry) => {
try {
const updated = await updateMemorySource(source.id, { enabled: !source.enabled });
setSources(prev => prev.map(s => (s.id === updated.id ? updated : s)));
} catch (err) {
onToast?.({
type: 'error',
title: t('memorySources.toggleFailed'),
message: err instanceof Error ? err.message : String(err),
});
}
},
[onToast, t]
);
const handleRemove = useCallback(
async (source: MemorySourceEntry) => {
try {
await removeMemorySource(source.id);
setSources(prev => prev.filter(s => s.id !== source.id));
onToast?.({ type: 'success', title: t('memorySources.removed'), message: source.label });
} catch (err) {
onToast?.({
type: 'error',
title: t('memorySources.removeFailed'),
message: err instanceof Error ? err.message : String(err),
});
}
},
[onToast, t]
);
const handleSync = useCallback(
async (source: MemorySourceEntry) => {
// RC#1 (#3295): add immediately on click — event will also fire, but this
// ensures the row lights up before the first sync-stage event arrives.
setSyncingIds(prev => {
const next = new Set(prev);
next.add(source.id);
return next;
});
// A fresh sync is starting — drop any prior terminal result chip (#3295).
setSyncResults(prev => {
if (!prev.has(source.id)) return prev;
const next = new Map(prev);
next.delete(source.id);
return next;
});
console.debug(`[ui-flow][memory-sync] manual sync triggered source_id=${source.id}`);
try {
await syncMemorySource(source.id);
// NOTE: success/failure feedback is intentionally NOT fired here. This
// RPC returns in ~4ms after merely *spawning* the background sync; the
// real outcome arrives via the terminal `completed`/`failed` stage event
// (handled above), which carries the item count / failure reason (#3295).
void refresh();
} catch (err) {
// The RPC call itself failed (transport/validation) — the background
// sync never started, so no stage event will arrive. Surface it here:
// clear the syncing flag and record a failed result on the row.
const reason = err instanceof Error ? err.message : String(err);
setSyncingIds(prev => {
const next = new Set(prev);
next.delete(source.id);
return next;
});
setSyncResults(prev => {
const next = new Map(prev);
next.set(source.id, { kind: 'failed', items: null, reason });
return next;
});
onToast?.({
type: 'error',
title: `${t('memorySources.sync.failedLabel')} · ${source.label}`,
message: reason,
});
}
// No `finally` clear: on success the row stays "syncing" until the
// terminal stage event arrives (the sync is still running in the
// background). Clearing here is what made the indicator vanish in ~4ms.
},
[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]);
onToast?.({ type: 'success', title: t('memorySources.added'), message: source.label });
void refresh();
},
[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-line bg-surface p-4" data-testid="memory-sources">
<header className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-content-secondary">{t('memorySources.title')}</h3>
<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-surface 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-surface 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
variant="primary"
size="sm"
onClick={() => setDialogOpen(true)}
leadingIcon={<PlusIcon />}>
{t('memorySources.addSource')}
</Button>
</div>
</header>
<MemorySyncSchedule lastSyncMs={overallLastSyncMs} onToast={onToast} />
{loading ? (
<p className="text-xs text-content-muted">{t('common.loading')}</p>
) : sources.length === 0 ? (
<p className="text-xs text-content-muted">{t('memorySources.empty')}</p>
) : (
<ul className="divide-y divide-line-subtle dark:divide-neutral-800">
{sources.map(source => (
<SourceRow
key={source.id}
source={source}
status={statusById.get(source.id) ?? null}
pipeline={pipeline}
isAuthenticated={isAuthenticated}
isSyncing={syncingIds.has(source.id) || syncProgress.has(source.id)}
isBuilding={buildingId === source.id}
progress={syncProgress.get(source.id) ?? null}
result={syncResults.get(source.id) ?? null}
settingsExpanded={expandedSettingsId === source.id}
onToggle={handleToggle}
onRemove={handleRemove}
onSync={handleSync}
onBuild={handleBuild}
onToggleSettings={handleToggleSettings}
onSettingsSaved={handleSettingsSaved}
onToast={onToast}
onViewHealth={() => {
console.debug('[ui-flow][memory-sources] view memory health from source row');
navigate('/brain?tab=sync');
}}
onSignIn={() => {
console.debug(
'[ui-flow][memory-sources] sign-in prompt from stored-without-vectors'
);
navigate('/auth');
}}
/>
))}
</ul>
)}
<AddMemorySourceDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onAdded={handleAdded}
/>
{allInModalOpen && (
<ConfirmationModal modal={allInModal} onClose={() => setAllInModalOpen(false)} />
)}
</section>
);
}
/** Manual-only sentinel — stored as `sync_interval_secs = 0`. */
const MANUAL_INTERVAL_SECS = 0;
/** Preset cadences offered in the UI (seconds): 4h / 12h / 24h. */
const SYNC_INTERVAL_PRESETS_SECS = [14_400, 43_200, 86_400];
/** Human label for a cadence ("Every 4h" / "Every 30m" / "Manual only"). */
function intervalChipLabel(secs: number, t: (k: string) => string): string {
if (secs === MANUAL_INTERVAL_SECS) return t('memorySyncInterval.manual');
if (secs % 3600 === 0) {
return t('memorySyncInterval.everyHours').replace('{h}', String(secs / 3600));
}
return t('memorySyncInterval.everyMinutes').replace('{m}', String(Math.round(secs / 60)));
}
interface MemorySyncScheduleProps {
lastSyncMs: number | null;
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
}
/**
* Global memory-sync schedule control (#3302). Presented like a backup
* schedule: "Last synced … · Sync every …", with preset cadences (4h / 12h /
* 24h) plus "Manual only". Reads/writes `config_*_memory_sync_settings`.
*/
function MemorySyncSchedule({ lastSyncMs, onToast }: MemorySyncScheduleProps) {
const { t } = useT();
const [settings, setSettings] = useState<MemorySyncSettings | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
let active = true;
const loadSettings = async () => {
try {
const resp = await openhumanGetMemorySyncSettings();
if (active) setSettings(resp.result);
} catch (err) {
log('get settings failed: %O', err);
}
};
void loadSettings();
return () => {
active = false;
};
}, []);
const handleSelect = useCallback(
async (secs: number) => {
setSaving(true);
try {
const resp = await openhumanUpdateMemorySyncSettings({ sync_interval_secs: secs });
setSettings(resp.result);
} catch (err) {
onToast?.({
type: 'error',
title: t('memorySyncInterval.saveFailed'),
message: err instanceof Error ? err.message : String(err),
});
} finally {
setSaving(false);
}
},
[onToast, t]
);
if (!settings) return null;
const lastSync = relativeTimestamp(lastSyncMs, t);
const currentLabel = settings.is_manual
? t('memorySyncInterval.manual')
: intervalChipLabel(settings.selected_secs, t);
// Option list: backend presets (fall back to the local defaults) + Manual.
const presetSecs =
settings.presets && settings.presets.length > 0 ? settings.presets : SYNC_INTERVAL_PRESETS_SECS;
const options = [...presetSecs, MANUAL_INTERVAL_SECS];
const selectedSecs = settings.is_manual ? MANUAL_INTERVAL_SECS : settings.selected_secs;
return (
<div
className="mb-3 rounded-md border border-line bg-surface-muted p-3"
data-testid="memory-sync-schedule">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-semibold text-content-secondary">
{t('memorySyncInterval.title')}
</p>
<p className="mt-0.5 text-xs text-content-muted">
<span>
{t('memorySyncInterval.lastSynced')} {lastSync ?? t('memorySyncInterval.never')}
</span>
<span aria-hidden="true"> · </span>
<span data-testid="memory-sync-current">{currentLabel}</span>
</p>
</div>
<div
role="radiogroup"
aria-label={t('memorySyncInterval.title')}
className="flex flex-wrap items-center gap-1.5">
{options.map(secs => {
const isSelected = secs === selectedSecs;
return (
<button
key={secs}
type="button"
role="radio"
aria-checked={isSelected}
disabled={saving}
onClick={() => void handleSelect(secs)}
data-testid={`memory-sync-preset-${secs}`}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors
focus:outline-none focus:ring-2 focus:ring-primary-200
disabled:cursor-not-allowed disabled:opacity-50 ${
isSelected
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-500/50 dark:bg-primary-500/10 dark:text-primary-300'
: 'border-line bg-surface text-content-secondary hover:bg-surface-hover'
}`}>
{intervalChipLabel(secs, t)}
</button>
);
})}
</div>
</div>
</div>
);
}
interface SourceRowProps {
source: MemorySourceEntry;
status: SourceStatus | null;
/** Global downstream pipeline health (GH-4690); `null` before first poll. */
pipeline: MemoryTreePipelineStatus | null;
/** Whether a backend session exists — gates the "Sign in to enable" prompt. */
isAuthenticated: boolean;
isSyncing: boolean;
isBuilding: boolean;
progress: SyncProgress | null;
result: SyncResult | 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;
/** Navigate to Brain > Memory > Sync (the full memory-health panel). */
onViewHealth: () => void;
/** Navigate to sign-in (embeddings need a backend session). */
onSignIn: () => void;
}
function SourceRow({
source,
status,
pipeline,
isAuthenticated,
isSyncing,
isBuilding,
progress,
result,
settingsExpanded,
onToggle,
onRemove,
onSync,
onBuild,
onToggleSettings,
onSettingsSaved,
onToast,
onViewHealth,
onSignIn,
}: SourceRowProps) {
const { t } = useT();
const icon = SOURCE_KIND_ICONS[source.kind] ?? '📄';
const kindLabel = t(SOURCE_KIND_LABEL_KEYS[source.kind] ?? source.kind);
const detail = sourceDetail(source);
const lastSync = status ? relativeTimestamp(status.last_chunk_at_ms, t) : null;
// GH-4690: layered pipeline verdict. Only meaningful in a settled state —
// during an active sync (`progress`) or right after one (`result`) the row
// renders live progress / a terminal chip instead, and `chunks_pending` is
// legitimately transient, so we suppress the warning until things settle.
const settled = !progress && !result;
const health: SourcePipelineHealth = settled
? deriveSourcePipelineHealth(status, pipeline)
: { state: 'none', issues: [], authRelated: false };
const ingestedOnly = health.state === 'ingested_only';
if (ingestedOnly) {
console.debug(
`[ui-flow][memory-sources] source=${source.id} ingested-only issues=${health.issues.join(',')}`
);
}
return (
<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-content' : 'text-content-faint line-through'
}`}>
{source.label}
</span>
<span className="rounded-md bg-surface-subtle px-1.5 py-0.5 text-[10px] font-medium text-content-muted">
{kindLabel}
</span>
{status &&
status.chunks_synced > 0 &&
(ingestedOnly ? (
<IngestedOnlyPill sourceId={source.id} />
) : (
<FreshnessPill freshness={status.freshness} />
))}
</div>
{detail && <p className="mt-0.5 truncate pl-7 text-xs text-content-faint">{detail}</p>}
{progress && (
<div className="mt-2 pl-7">
<div className="flex items-center gap-2 text-xs text-content-muted">
<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-content-faint">{progress.detail}</span>
)}
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full rounded-full bg-primary-500 transition-all duration-300"
style={{
width: `${progress.percent ?? STAGE_FALLBACK_PERCENT[progress.stage] ?? 2}%`,
}}
/>
</div>
</div>
)}
{!progress && result && (
<div className="mt-2 pl-7" data-testid={`memory-source-result-${source.id}`}>
{result.kind === 'success' ? (
<span className="inline-flex items-center gap-1 rounded-md bg-sage-100 px-2 py-0.5 text-xs font-medium text-sage-700 dark:bg-sage-500/20 dark:text-sage-300">
<CheckIcon />
{result.items && result.items > 0
? `${result.items.toLocaleString()} ${t('memorySources.sync.itemsSynced')}`
: t('memorySources.sync.upToDate')}
</span>
) : (
<span
className="inline-flex items-start gap-1 rounded-md bg-coral-50 px-2 py-0.5 text-xs font-medium text-coral-700 dark:bg-coral-500/10 dark:text-coral-300"
title={result.reason ?? undefined}>
<WarnIcon />
<span className="break-words">
{t('memorySources.sync.failedLabel')}
{result.reason ? `: ${result.reason}` : ''}
</span>
</span>
)}
</div>
)}
{!progress &&
!result &&
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-content-muted">
<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>
)}
{ingestedOnly && (
<div
className="mt-2 flex flex-col gap-1 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 pl-7 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
data-testid={`memory-source-pipeline-warning-${source.id}`}
role="status">
{health.issues.map(issue => (
<div key={issue} className="flex items-start gap-1.5">
<WarnIcon />
<span className="break-words">{t(pipelineIssueMessageKey(issue))}</span>
</div>
))}
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 pl-[18px]">
{health.authRelated && !isAuthenticated && (
<button
type="button"
onClick={onSignIn}
data-testid={`memory-source-signin-${source.id}`}
className="font-medium text-amber-900 underline underline-offset-2 hover:text-amber-950 dark:text-amber-100 dark:hover:text-white">
{t('sync.pipeline.signInToEnable')}
</button>
)}
<button
type="button"
onClick={onViewHealth}
data-testid={`memory-source-view-health-${source.id}`}
className="font-medium text-amber-900 underline underline-offset-2 hover:text-amber-950 dark:text-amber-100 dark:hover:text-white">
{t('sync.pipeline.viewHealth')}
</button>
</div>
</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-content-faint hover:bg-surface-hover hover:text-content-secondary'
}`}>
<GearIcon />
</button>
<Button
variant="primary"
size="sm"
onClick={() => onSync(source)}
disabled={!source.enabled || isSyncing}
title={t('sync.sync')}
data-testid={`memory-source-sync-${source.toolkit ?? source.kind}`}
leadingIcon={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-surface 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-surface 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-surface-strong'
}`}>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-surface shadow transition-transform ${
source.enabled ? 'left-[18px]' : 'left-0.5'
}`}
/>
</button>
<Button
iconOnly
variant="tertiary"
tone="danger"
size="xs"
onClick={() => onRemove(source)}
title={t('memorySources.remove')}
aria-label={t('memorySources.remove')}>
<TrashIcon />
</Button>
</div>
</div>
{settingsExpanded && (
<SourceSettingsPanel
source={source}
syncedCount={status?.chunks_synced}
onSaved={onSettingsSaved}
onToast={onToast}
/>
)}
</li>
);
}
function FreshnessPill({ freshness }: { freshness: FreshnessLabel }) {
const { t } = useT();
const label =
freshness === 'active'
? t('sync.active')
: freshness === 'recent'
? t('sync.recent')
: t('sync.idle');
const cls =
freshness === 'active'
? 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
: freshness === 'recent'
? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
: 'bg-surface-subtle text-content-secondary';
return <span className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${cls}`}>{label}</span>;
}
/**
* GH-4690: the "Ingested only" warning pill. Replaces the clean freshness pill
* when a source ingested chunks but the downstream retrieval pipeline failed
* (no vectors / extraction / degraded tree). Amber, matching the degraded
* badges in the Brain > Memory > Sync panel, so raw sync ≠ retrieval-ready is
* unmistakable at a glance.
*/
function IngestedOnlyPill({ sourceId }: { sourceId: string }) {
const { t } = useT();
return (
<span
className="rounded-md bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-500/20 dark:text-amber-200"
data-testid={`memory-source-ingested-only-${sourceId}`}>
{t('sync.pipeline.ingestedOnly')}
</span>
);
}
function relativeTimestamp(epochMs: number | null, t: (k: string) => string): string | null {
if (epochMs === null) return null;
const delta = Date.now() - epochMs;
if (delta < 1000) return t('time.justNow');
const seconds = Math.floor(delta / 1000);
if (seconds < 60) return `${seconds}${t('time.secondsAgoSuffix')}`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}${t('time.minutesAgoSuffix')}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}${t('time.hoursAgoSuffix')}`;
const days = Math.floor(hours / 24);
return `${days}${t('time.daysAgoSuffix')}`;
}
function sourceTreeScope(source: MemorySourceEntry): string | null {
if (source.kind === 'github_repo' && source.url) {
const m = source.url.match(/github\.com\/([^/]+)\/([^/.]+)/);
if (m) return `github:${m[1]}/${m[2]}`;
}
return source.id;
}
function sourceDetail(source: MemorySourceEntry): string | null {
switch (source.kind) {
case 'composio': {
const parts = [source.toolkit, source.connection_id].filter(Boolean);
return parts.length ? parts.join(' · ') : null;
}
case 'folder':
return source.path ?? null;
case 'github_repo':
return source.url ?? null;
case 'rss_feed':
return source.url ?? null;
case 'web_page':
return source.url ?? null;
case 'twitter_query':
return source.query ?? null;
default:
return null;
}
}
function PlusIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
);
}
function TrashIcon() {
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">
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
</svg>
);
}
function BuildIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
<path d="M22 4L12 14.01l-3-3" />
</svg>
);
}
function SyncIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M21 12a9 9 0 11-3-6.7" />
<path d="M21 4v5h-5" />
</svg>
);
}
function Spinner() {
return (
<svg
className="animate-spin"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true">
<circle cx="12" cy="12" r="9" opacity="0.25" />
<path d="M21 12a9 9 0 00-9-9" />
</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 CheckIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
function WarnIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="mt-0.5 shrink-0">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<path d="M12 9v4M12 17h.01" />
</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>
);
}