/** * Dialog for adding a new memory source. * * Step 1: pick a source kind (Composio / Folder / GitHub / RSS / Web / Twitter). * Step 2: fill in kind-specific fields and submit. * * For Composio, the dialog fetches the user's active connections and * presents them as a dropdown — the user picks an existing OAuth * connection rather than typing toolkit + connection_id. */ import debug from 'debug'; import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { listConnections } from '../../lib/composio/composioApi'; import type { ComposioConnection } from '../../lib/composio/types'; import { useT } from '../../lib/i18n/I18nContext'; import { addMemorySource, getSupportedToolkits, type MemorySourceEntry, SOURCE_KIND_ICONS, SOURCE_KIND_LABEL_KEYS, type SourceKind, } from '../../services/memorySourcesService'; import Button from '../ui/Button'; const log = debug('intelligence:add-memory-source-dialog'); /** Safe, PII-free string for an unknown error — message/name only, no stack. */ function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } interface AddMemorySourceDialogProps { open: boolean; onClose: () => void; onAdded: (source: MemorySourceEntry) => void; } const ALL_KINDS: SourceKind[] = [ 'composio', 'conversation', 'folder', 'github_repo', 'rss_feed', 'web_page', 'twitter_query', ]; export function AddMemorySourceDialog({ open, onClose, onAdded }: AddMemorySourceDialogProps) { const { t } = useT(); const [kind, setKind] = useState(null); const [label, setLabel] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // Kind-specific fields const [path, setPath] = useState(''); const [glob, setGlob] = useState('**/*.md'); const [url, setUrl] = useState(''); const [branch, setBranch] = useState('main'); const [query, setQuery] = useState(''); const [selector, setSelector] = useState(''); const [connectionId, setConnectionId] = useState(''); const [toolkit, setToolkit] = useState(''); // Composio connection picker state const [connections, setConnections] = useState([]); const [loadingConnections, setLoadingConnections] = useState(false); // Toolkit slugs that can actually sync (backend registry). `null` means the // set hasn't loaded (or the RPC failed) — in that case we treat every // connection as supported rather than locking the user out of all of them. const [supportedToolkits, setSupportedToolkits] = useState(null); // Fetch composio connections + the supported-toolkit set when the user picks // the composio kind. setState calls live inside the spawned async closure // (not the synchronous effect body) to satisfy `react-hooks/set-state-in-effect`. useEffect(() => { if (kind !== 'composio') return undefined; let cancelled = false; void (async () => { if (cancelled) return; setLoadingConnections(true); try { const [resp, toolkits] = await Promise.all([ listConnections(), getSupportedToolkits().catch((err: unknown) => { // Non-fatal: fall back to "everything supported" so the picker // still works if the supported-toolkit RPC is unavailable. log('[composio-picker] getSupportedToolkits failed: %s', errMessage(err)); return null; }), ]); if (cancelled) return; setConnections(resp.connections); setSupportedToolkits(toolkits); } catch (err) { if (cancelled) return; log('[add-memory-source] listConnections failed: %s', errMessage(err)); setError(t('memorySources.composioListFailed')); } finally { if (!cancelled) setLoadingConnections(false); } })(); return () => { cancelled = true; }; }, [kind, t]); const reset = useCallback(() => { setKind(null); setLabel(''); setPath(''); setGlob('**/*.md'); setUrl(''); setBranch('main'); setQuery(''); setSelector(''); setConnectionId(''); setToolkit(''); setError(null); }, []); const handleClose = useCallback(() => { reset(); onClose(); }, [onClose, reset]); const handleSubmit = useCallback(async () => { if (!kind || !label.trim()) return; setSubmitting(true); setError(null); try { const params: Record = { kind, label: label.trim(), enabled: true }; switch (kind) { case 'composio': params.toolkit = toolkit; params.connection_id = connectionId; break; case 'conversation': break; case 'folder': params.path = path.trim(); params.glob = glob.trim() || '**/*.md'; break; case 'github_repo': params.url = url.trim(); params.branch = branch.trim() || 'main'; break; case 'rss_feed': params.url = url.trim(); break; case 'web_page': params.url = url.trim(); if (selector.trim()) params.selector = selector.trim(); break; case 'twitter_query': params.query = query.trim(); break; } const source = await addMemorySource(params as Omit); onAdded(source); handleClose(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }, [ kind, label, path, glob, url, branch, query, selector, connectionId, toolkit, onAdded, handleClose, ]); if (!open) return null; const isValid = kind && label.trim() && isKindFieldsValid(kind, { path, url, query, connectionId }); return (

{t('memorySources.addSource')}

{!kind ? ( <>

{t('memorySources.pickKind')}

{ALL_KINDS.map(k => ( ))}
) : ( <>

{SOURCE_KIND_ICONS[kind]} {t(SOURCE_KIND_LABEL_KEYS[kind])}

{ setConnectionId(connId); setToolkit(tk); if (!label) setLabel(identityLabel); }} />
{error && (

{error}

)}
)}
); } function isKindFieldsValid( kind: SourceKind, fields: { path: string; url: string; query: string; connectionId: string } ): boolean { switch (kind) { case 'composio': return fields.connectionId.length > 0; case 'conversation': return true; case 'folder': return fields.path.trim().length > 0; case 'github_repo': case 'rss_feed': case 'web_page': return fields.url.trim().length > 0; case 'twitter_query': return fields.query.trim().length > 0; default: return true; } } interface FieldProps { label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string; } interface FolderFieldProps { label: string; value: string; onChange: (v: string) => void; } function FolderField({ label, value, onChange }: FolderFieldProps) { const { t } = useT(); return ( ); } function Field({ label, value, onChange, placeholder, type = 'text' }: FieldProps) { return ( ); } interface KindFieldsProps { kind: SourceKind; path: string; setPath: (v: string) => void; glob: string; setGlob: (v: string) => void; url: string; setUrl: (v: string) => void; branch: string; setBranch: (v: string) => void; query: string; setQuery: (v: string) => void; selector: string; setSelector: (v: string) => void; connections: ComposioConnection[]; loadingConnections: boolean; /** Syncable toolkit slugs; `null` while unknown (treat all as supported). */ supportedToolkits: string[] | null; connectionId: string; setConnection: (connectionId: string, toolkit: string, identityLabel: string) => void; } function KindFields(props: KindFieldsProps) { const { t } = useT(); switch (props.kind) { case 'composio': return ; case 'conversation': return null; case 'folder': return ( <> ); case 'github_repo': return ( <> ); case 'rss_feed': return ( ); case 'web_page': return ( <> ); case 'twitter_query': return ( ); default: return null; } } /** Active-first status rank — lower is better. */ const STATUS_RANK: Record = { ACTIVE: 0, CONNECTED: 0, PENDING: 1, INITIATED: 1, INITIALIZING: 1, EXPIRED: 2, FAILED: 3, ERROR: 3, }; function statusRank(conn: ComposioConnection): number { return STATUS_RANK[conn.status.toUpperCase()] ?? 2; } /** * Deduplicates and labels connections for display in the picker. * * - Sorts by status rank first (ACTIVE/CONNECTED before EXPIRED/FAILED) so * that when two connections share the same toolkit + identity, the healthier * one wins rather than the first-returned one. * - Connections sharing the same toolkit + identity (accountEmail / workspace / * username) OR the same raw connection id are collapsed to the first * occurrence, preventing both labeled and identity-less duplicates. * - Connections with no identity field fall back to showing the raw connection ID * so users can unambiguously distinguish accounts. */ export function deduplicateConnections( connections: ComposioConnection[] ): Array<{ conn: ComposioConnection; label: string }> { const sorted = [...connections].sort((a, b) => statusRank(a) - statusRank(b)); const seen = new Set(); const result: Array<{ conn: ComposioConnection; label: string }> = []; for (const conn of sorted) { // Always dedup by raw connection id to guard against identity-less dupes. if (seen.has(conn.id)) { log('[composio-picker] dropping duplicate connection toolkit=%s', conn.toolkit); continue; } seen.add(conn.id); const identity = conn.accountEmail ?? conn.workspace ?? conn.username; if (identity) { const key = `${conn.toolkit}:${identity}`; if (seen.has(key)) { log('[composio-picker] dropping duplicate connection toolkit=%s', conn.toolkit); continue; } seen.add(key); result.push({ conn, label: `${conn.toolkit} · ${identity}` }); } else { // Fall back to the raw connection ID so the user can unambiguously // distinguish accounts when no identity data is available. result.push({ conn, label: `${conn.toolkit} · ${conn.id}` }); } } return result; } /** A connection is syncable when its toolkit ships a provider. A `null` * supported-set means "unknown" — treat everything as supported so a failed * lookup never disables the whole picker. */ function isToolkitSupported(toolkit: string, supportedToolkits: string[] | null): boolean { if (supportedToolkits === null) return true; return supportedToolkits.includes(toolkit.trim().toLowerCase()); } interface PickerEntry { conn: ComposioConnection; label: string; supported: boolean; } function ComposioPicker({ connections, loadingConnections, supportedToolkits, connectionId, setConnection, }: KindFieldsProps) { const { t } = useT(); const [open, setOpen] = useState(false); // Index (into `entries`) of the keyboard-highlighted option; -1 when none. const [activeIndex, setActiveIndex] = useState(-1); const containerRef = useRef(null); const buttonRef = useRef(null); const listboxRef = useRef(null); // useMemo must be declared before any early returns (Rules of Hooks). const entries = useMemo(() => { const deduped = deduplicateConnections(connections).map(({ conn, label }) => ({ conn, label, supported: isToolkitSupported(conn.toolkit, supportedToolkits), })); // Supported connections first so the actionable ones surface at the top; // stable within each partition (dedup already ranked by health/status). return [...deduped.filter(e => e.supported), ...deduped.filter(e => !e.supported)]; }, [connections, supportedToolkits]); // Indexes of keyboard-selectable (supported) options — unsupported rows are // skipped during arrow navigation, mirroring a native