diff --git a/app/src/components/intelligence/AddMemorySourceDialog.test.tsx b/app/src/components/intelligence/AddMemorySourceDialog.test.tsx index a75d88ba2..83fcbc50e 100644 --- a/app/src/components/intelligence/AddMemorySourceDialog.test.tsx +++ b/app/src/components/intelligence/AddMemorySourceDialog.test.tsx @@ -7,6 +7,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { listConnections } from '../../lib/composio/composioApi'; +import { getSupportedToolkits } from '../../services/memorySourcesService'; import { renderWithProviders } from '../../test/test-utils'; import { AddMemorySourceDialog, deduplicateConnections } from './AddMemorySourceDialog'; @@ -18,6 +19,7 @@ vi.mock('../../lib/composio/composioApi', () => ({ listConnections: vi.fn() })); vi.mock('../../services/memorySourcesService', () => ({ addMemorySource: vi.fn(), + getSupportedToolkits: vi.fn(), SOURCE_KIND_ICONS: { folder: '📁', composio: '🔗', @@ -37,6 +39,12 @@ vi.mock('../../services/memorySourcesService', () => ({ })); const mockListConnections = listConnections as ReturnType; +const mockGetSupportedToolkits = getSupportedToolkits as ReturnType; + +/** Every toolkit used across the picker component tests is syncable by default, + * so existing assertions keep passing. Tests that exercise the disabled / + * "Coming soon" path override this with a narrower set. */ +const DEFAULT_SUPPORTED = ['gmail', 'slack', 'notion', 'github', 'linear', 'clickup']; // --------------------------------------------------------------------------- // Helper @@ -58,6 +66,13 @@ async function openComposioStep() { await waitFor(() => expect(mockListConnections).toHaveBeenCalledTimes(1)); } +/** Open the custom connection dropdown so its option rows render. */ +async function openListbox() { + const trigger = await screen.findByTestId('composio-connection-picker'); + fireEvent.click(trigger); + await screen.findByTestId('composio-connection-listbox'); +} + // --------------------------------------------------------------------------- // Unit tests: deduplicateConnections helper // --------------------------------------------------------------------------- @@ -195,6 +210,8 @@ describe('deduplicateConnections', () => { describe('AddMemorySourceDialog — Composio picker', () => { beforeEach(() => { mockListConnections.mockReset(); + mockGetSupportedToolkits.mockReset(); + mockGetSupportedToolkits.mockResolvedValue(DEFAULT_SUPPORTED); }); it('shows loading state while fetching connections', async () => { @@ -222,6 +239,7 @@ describe('AddMemorySourceDialog — Composio picker', () => { ], }); await openComposioStep(); + await openListbox(); await waitFor(() => expect(screen.queryByText('Gmail · user@gmail.com')).toBeTruthy()); expect(screen.queryByText('raw-id-xyz')).toBeNull(); }); @@ -234,6 +252,7 @@ describe('AddMemorySourceDialog — Composio picker', () => { ], }); await openComposioStep(); + await openListbox(); await waitFor(() => { const options = screen.getAllByRole('option'); const gmailOptions = options.filter(o => o.textContent?.includes('Gmail · x@example.com')); @@ -249,6 +268,7 @@ describe('AddMemorySourceDialog — Composio picker', () => { ], }); await openComposioStep(); + await openListbox(); await waitFor(() => { expect(screen.queryByText('Notion · Account 1')).toBeTruthy(); expect(screen.queryByText('Notion · Account 2')).toBeTruthy(); @@ -265,15 +285,139 @@ describe('AddMemorySourceDialog — Composio picker', () => { ], }); await openComposioStep(); - await waitFor(() => expect(screen.queryByText('Slack · my-workspace')).toBeTruthy()); + await openListbox(); + const option = await screen.findByTestId('composio-option-conn-1'); + fireEvent.click(option); - const select = screen.getByRole('combobox'); - fireEvent.change(select, { target: { value: 'conn-1' } }); - - // The label field should be auto-filled + // The label field should be auto-filled, and the dropdown collapses to + // show the chosen connection on the trigger. await waitFor(() => { const labelInput = screen.getByPlaceholderText('My research notes'); expect((labelInput as HTMLInputElement).value).toBe('Slack · my-workspace'); }); }); + + it('disables unsupported toolkits with a "Coming soon" tag and keeps them unselectable', async () => { + // Slack is syncable; Google Calendar is not in the supported set. + mockGetSupportedToolkits.mockResolvedValue(['slack']); + mockListConnections.mockResolvedValue({ + connections: [ + { id: 'conn-slack', toolkit: 'slack', status: 'ACTIVE', workspace: 'acme' }, + { id: 'conn-gcal', toolkit: 'googlecalendar', status: 'ACTIVE', accountEmail: 'a@x.com' }, + ], + }); + await openComposioStep(); + await openListbox(); + + const supported = await screen.findByTestId('composio-option-conn-slack'); + const unsupported = await screen.findByTestId('composio-option-conn-gcal'); + + expect(supported.getAttribute('aria-disabled')).toBe('false'); + expect(unsupported.getAttribute('aria-disabled')).toBe('true'); + expect(screen.getByTestId('composio-option-coming-soon-conn-gcal')).toBeInTheDocument(); + // No "Coming soon" chip on the supported row. + expect(screen.queryByTestId('composio-option-coming-soon-conn-slack')).toBeNull(); + + // Clicking the unsupported row must NOT select it (label stays empty). + fireEvent.click(unsupported); + const labelInput = screen.getByPlaceholderText('My research notes'); + expect((labelInput as HTMLInputElement).value).toBe(''); + + // Clicking the supported row selects it. + fireEvent.click(supported); + await waitFor(() => expect((labelInput as HTMLInputElement).value).toBe('slack · acme')); + }); + + it('treats every connection as supported when the supported-toolkit RPC fails', async () => { + // Fallback path: getSupportedToolkits rejects → null set → nothing disabled. + mockGetSupportedToolkits.mockRejectedValue(new Error('rpc down')); + mockListConnections.mockResolvedValue({ + connections: [{ id: 'conn-x', toolkit: 'sentry', status: 'ACTIVE', accountEmail: 'a@x.com' }], + }); + await openComposioStep(); + await openListbox(); + const option = await screen.findByTestId('composio-option-conn-x'); + expect(option.getAttribute('aria-disabled')).toBe('false'); + expect(screen.queryByTestId('composio-option-coming-soon-conn-x')).toBeNull(); + }); + + it('closes the dropdown when Escape is pressed', async () => { + mockListConnections.mockResolvedValue({ + connections: [{ id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }], + }); + await openComposioStep(); + await openListbox(); + expect(screen.queryByTestId('composio-connection-listbox')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByTestId('composio-connection-listbox')).toBeNull()); + }); + + it('closes the dropdown on an outside click', async () => { + mockListConnections.mockResolvedValue({ + connections: [{ id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }], + }); + await openComposioStep(); + await openListbox(); + expect(screen.queryByTestId('composio-connection-listbox')).toBeInTheDocument(); + + // A mousedown outside the picker container collapses the listbox. + fireEvent.mouseDown(document.body); + await waitFor(() => expect(screen.queryByTestId('composio-connection-listbox')).toBeNull()); + }); + + it('opens the listbox with ArrowDown on the trigger button', async () => { + mockListConnections.mockResolvedValue({ + connections: [{ id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }], + }); + await openComposioStep(); + const trigger = await screen.findByTestId('composio-connection-picker'); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + await screen.findByTestId('composio-connection-listbox'); + }); + + it('navigates options with the arrow keys and selects with Enter', async () => { + mockListConnections.mockResolvedValue({ + connections: [ + { id: 'conn-gmail', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@x.com' }, + { id: 'conn-slack', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }, + ], + }); + await openComposioStep(); + await openListbox(); + const listbox = screen.getByTestId('composio-connection-listbox'); + + // Opens highlighting the first selectable option; ArrowDown moves to the next. + expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-gmail'); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); + + // Enter selects the highlighted option and closes the dropdown. + fireEvent.keyDown(listbox, { key: 'Enter' }); + await waitFor(() => { + const labelInput = screen.getByPlaceholderText('My research notes'); + expect((labelInput as HTMLInputElement).value).toBe('Slack · acme'); + }); + expect(screen.queryByTestId('composio-connection-listbox')).toBeNull(); + }); + + it('skips unsupported options during keyboard navigation', async () => { + mockGetSupportedToolkits.mockResolvedValue(['slack']); + mockListConnections.mockResolvedValue({ + connections: [ + { id: 'conn-slack', toolkit: 'slack', status: 'ACTIVE', workspace: 'acme' }, + { id: 'conn-gcal', toolkit: 'googlecalendar', status: 'ACTIVE', accountEmail: 'a@x.com' }, + ], + }); + await openComposioStep(); + await openListbox(); + const listbox = screen.getByTestId('composio-connection-listbox'); + + // Only the supported option is reachable; wrapping keeps it on slack. + expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); + fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); + }); }); diff --git a/app/src/components/intelligence/AddMemorySourceDialog.tsx b/app/src/components/intelligence/AddMemorySourceDialog.tsx index 5277c5160..280de882d 100644 --- a/app/src/components/intelligence/AddMemorySourceDialog.tsx +++ b/app/src/components/intelligence/AddMemorySourceDialog.tsx @@ -8,19 +8,35 @@ * presents them as a dropdown — the user picks an existing OAuth * connection rather than typing toolkit + connection_id. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +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'; +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; @@ -56,10 +72,14 @@ export function AddMemorySourceDialog({ open, onClose, onAdded }: AddMemorySourc // 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 when 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`. + // 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; @@ -67,12 +87,21 @@ export function AddMemorySourceDialog({ open, onClose, onAdded }: AddMemorySourc if (cancelled) return; setLoadingConnections(true); try { - const resp = await listConnections(); + 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; - console.warn('[ui-flow][add-memory-source] listConnections failed', err); + log('[add-memory-source] listConnections failed: %s', errMessage(err)); setError(t('memorySources.composioListFailed')); } finally { if (!cancelled) setLoadingConnections(false); @@ -230,6 +259,7 @@ export function AddMemorySourceDialog({ open, onClose, onAdded }: AddMemorySourc setSelector={setSelector} connections={connections} loadingConnections={loadingConnections} + supportedToolkits={supportedToolkits} connectionId={connectionId} setConnection={(connId, tk, identityLabel) => { setConnectionId(connId); @@ -405,6 +435,8 @@ interface KindFieldsProps { 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; } @@ -527,9 +559,7 @@ export function deduplicateConnections( for (const conn of sorted) { // Always dedup by raw connection id to guard against identity-less dupes. if (seen.has(conn.id)) { - console.debug( - `[ui-flow][composio-picker] dropping duplicate connection toolkit=${conn.toolkit} id=${conn.id}` - ); + log('[composio-picker] dropping duplicate connection toolkit=%s', conn.toolkit); continue; } seen.add(conn.id); @@ -538,9 +568,7 @@ export function deduplicateConnections( if (identity) { const key = `${conn.toolkit}:${identity}`; if (seen.has(key)) { - console.debug( - `[ui-flow][composio-picker] dropping duplicate connection toolkit=${conn.toolkit} id=${conn.id}` - ); + log('[composio-picker] dropping duplicate connection toolkit=%s', conn.toolkit); continue; } seen.add(key); @@ -554,20 +582,83 @@ export function deduplicateConnections( 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 accountLabel = t('memorySources.connectionAccount'); - const dedupedConnections = useMemo( - () => deduplicateConnections(connections, accountLabel), - [connections, accountLabel] + const entries = useMemo(() => { + const deduped = deduplicateConnections(connections, accountLabel).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, accountLabel, supportedToolkits]); + + // Indexes of keyboard-selectable (supported) options — unsupported rows are + // skipped during arrow navigation, mirroring a native { - const entry = dedupedConnections.find(({ conn }) => conn.id === e.target.value); - if (entry) { - setConnection(entry.conn.id, entry.conn.toolkit, entry.label); - } - }} - className="mt-1 block w-full rounded-md border border-stone-300 bg-white px-3 py-2 - text-sm text-stone-900 focus:border-primary-400 focus:outline-none - focus:ring-1 focus:ring-primary-400 dark:border-neutral-600 - dark:bg-neutral-800 dark:text-neutral-100 dark:focus:border-primary-500"> - - {dedupedConnections.map(({ conn, label }) => ( - - ))} - - +
+ + + {open && ( +
    + {entries.map((entry, index) => { + const isSelected = entry.conn.id === connectionId; + const isActive = index === activeIndex; + return ( +
  • select(entry)} + onMouseEnter={() => entry.supported && setActiveIndex(index)} + className={[ + 'flex items-center justify-between gap-2 px-3 py-2 text-sm', + entry.supported + ? 'cursor-pointer text-stone-800 dark:text-neutral-200' + : 'cursor-not-allowed text-stone-400 dark:text-neutral-500', + isActive && entry.supported ? 'bg-primary-50 dark:bg-primary-500/10' : '', + ].join(' ')}> + + {isSelected && entry.supported && ( + + ✓ + + )} + {entry.label} + + {!entry.supported && ( + + {t('memorySources.comingSoon')} + + )} +
  • + ); + })} +
+ )} +
+ ); } diff --git a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx index f90c3b623..c18e25c3b 100644 --- a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx +++ b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx @@ -25,6 +25,7 @@ vi.mock('../../../services/memorySourcesService', () => ({ removeMemorySource: vi.fn(), updateMemorySource: vi.fn(), addMemorySource: vi.fn(), + getSupportedToolkits: vi.fn().mockResolvedValue([]), SOURCE_KIND_ICONS: { folder: '📁', composio: '🔗', diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 99b60fc7c..0074c12a5 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1937,6 +1937,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'الحساب', 'memorySources.pickConnection': 'اختر اتصال', 'memorySources.selectConnection': '- اختيار اتصال -', + 'memorySources.comingSoon': 'قريباً', 'memorySources.composioListFailed': 'فشل في تحميل الأتصالات Xqx0x.', 'memorySources.browse': '(بروز)...', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index cde813146..8feb8c607 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1976,6 +1976,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'অ্যাকাউন্ট', 'memorySources.pickConnection': 'একটি সংযোগ নির্বাচন করুন', 'memorySources.selectConnection': '- একটি সংযোগ নির্বাচন করুন -', + 'memorySources.comingSoon': 'শীঘ্রই আসছে', 'memorySources.composioListFailed': 'Xqxqx সংযোগ লোড করতে ব্যর্থ।', 'memorySources.browse': 'ব্রাউজ করুন...', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index cda4f8d4a..0fddf74b4 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2027,6 +2027,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Konto', 'memorySources.pickConnection': 'Wählen Sie eine Verbindung', 'memorySources.selectConnection': 'Wählen Sie eine Verbindung aus.', + 'memorySources.comingSoon': 'Demnächst', 'memorySources.composioListFailed': 'Fehler beim Laden der Composio-Verbindungen.', 'memorySources.browse': 'Durchsuchen…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 646ad3bc8..f5e80b0c0 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2333,6 +2333,7 @@ const en: TranslationMap = { 'memorySources.connectionAccount': 'Account', 'memorySources.pickConnection': 'Pick a connection', 'memorySources.selectConnection': '— Select a connection —', + 'memorySources.comingSoon': 'Coming soon', 'memorySources.composioListFailed': 'Failed to load Composio connections.', 'memorySources.browse': 'Browse…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 11784b74e..4f11eb404 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2019,6 +2019,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Cuenta', 'memorySources.pickConnection': 'Elige una conexión', 'memorySources.selectConnection': '— Seleccionar una conexión —', + 'memorySources.comingSoon': 'Próximamente', 'memorySources.composioListFailed': 'Error al cargar las conexiones Composio.', 'memorySources.browse': 'Examinar…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index c030ca8cb..a1132594a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2025,6 +2025,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Compte', 'memorySources.pickConnection': 'Choisissez une connexion', 'memorySources.selectConnection': '— Sélectionnez une connexion —', + 'memorySources.comingSoon': 'Bientôt disponible', 'memorySources.composioListFailed': 'Échec du chargement des connexions Composio.', 'memorySources.browse': 'Parcourir…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 06d9738fa..0c0c59654 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1977,6 +1977,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'खाता', 'memorySources.pickConnection': 'कनेक्शन चुनें', 'memorySources.selectConnection': '- एक कनेक्शन चुनें -', + 'memorySources.comingSoon': 'जल्द आ रहा है', 'memorySources.composioListFailed': 'Composio कनेक्शन लोड करने में विफल रहा।', 'memorySources.browse': 'ब्राउज़ करें', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 5970de095..876749006 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1980,6 +1980,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Akun', 'memorySources.pickConnection': 'Pilih koneksi', 'memorySources.selectConnection': '- Pilih koneksi -', + 'memorySources.comingSoon': 'Segera hadir', 'memorySources.composioListFailed': 'Gagal memuat koneksi Composio.', 'memorySources.browse': 'Jelajahi...', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index e9cda236b..5d32c4ec7 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2009,6 +2009,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Account', 'memorySources.pickConnection': 'Scegli una connessione', 'memorySources.selectConnection': '— Seleziona una connessione —', + 'memorySources.comingSoon': 'Prossimamente', 'memorySources.composioListFailed': 'Impossibile caricare le connessioni Composio.', 'memorySources.browse': 'Sfoglia…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6b77c576a..996be46b5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1954,6 +1954,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': '계정', 'memorySources.pickConnection': '연결 선택', 'memorySources.selectConnection': '— 연결 선택 —', + 'memorySources.comingSoon': '출시 예정', 'memorySources.composioListFailed': 'Composio 연결을 불러오지 못했습니다.', 'memorySources.browse': '찾아보기…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 51f05a354..ca1e4035f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1998,6 +1998,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Konto', 'memorySources.pickConnection': 'Wybierz połączenie', 'memorySources.selectConnection': '— Wybierz połączenie —', + 'memorySources.comingSoon': 'Wkrótce', 'memorySources.composioListFailed': 'Nie udało się wczytać połączeń Composio.', 'memorySources.browse': 'Przeglądaj…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 828ee0a32..fb580a19a 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2017,6 +2017,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Conta', 'memorySources.pickConnection': 'Escolha uma conexão', 'memorySources.selectConnection': '— Selecionar uma conexão —', + 'memorySources.comingSoon': 'Em breve', 'memorySources.composioListFailed': 'Falha ao carregar as conexões Composio.', 'memorySources.browse': 'Navegar…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 1feade545..b4b856d64 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1990,6 +1990,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': 'Аккаунт', 'memorySources.pickConnection': 'Выберите соединение', 'memorySources.selectConnection': '— Выберите соединение —', + 'memorySources.comingSoon': 'Скоро', 'memorySources.composioListFailed': 'Не удалось загрузить соединения Composio.', 'memorySources.browse': 'Просматривать…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 31eda52be..99e49c709 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1876,6 +1876,7 @@ const messages: TranslationMap = { 'memorySources.connectionAccount': '账户', 'memorySources.pickConnection': '选择连接', 'memorySources.selectConnection': '— 选择连接 —', + 'memorySources.comingSoon': '即将推出', 'memorySources.composioListFailed': '加载 Composio 连接失败。', 'memorySources.browse': '浏览…', 'memorySources.folderPathPlaceholder': '/Users/you/notes', diff --git a/app/src/services/memorySourcesService.ts b/app/src/services/memorySourcesService.ts index f954320fe..47f6023b9 100644 --- a/app/src/services/memorySourcesService.ts +++ b/app/src/services/memorySourcesService.ts @@ -166,6 +166,21 @@ export async function syncMemorySource(sourceId: string): Promise { }); } +/** + * Toolkit slugs that ship a native memory-sync provider (backend registry — + * `all_providers()`). The Add Source connection picker uses this to disable + * connections whose toolkit can never sync. Maps to + * `openhuman.memory_sources_supported_toolkits`. See issue #3352. + */ +export async function getSupportedToolkits(): Promise { + log('supported_toolkits'); + const resp = await callCoreRpc<{ toolkits: string[] }>({ + method: 'openhuman.memory_sources_supported_toolkits', + }); + const data = unwrap<{ toolkits: string[] }>(resp); + return data.toolkits ?? []; +} + export interface ApplyAllInResult { sources: MemorySourceEntry[]; sync_triggered: number; diff --git a/src/openhuman/memory_sources/rpc.rs b/src/openhuman/memory_sources/rpc.rs index 431f5eca8..f2eb5041d 100644 --- a/src/openhuman/memory_sources/rpc.rs +++ b/src/openhuman/memory_sources/rpc.rs @@ -314,6 +314,47 @@ pub async fn status_list_rpc() -> Result, String> Ok(RpcOutcome::new(StatusListResponse { statuses }, vec![])) } +// ── Supported Toolkits ── + +#[derive(Debug, serde::Serialize)] +pub struct SupportedToolkitsResponse { + /// Sorted, de-duplicated toolkit slugs that ship a native memory-sync + /// provider (e.g. `clickup`, `github`, `gmail`, `linear`, `notion`, + /// `slack`). Anything outside this set can never sync. + pub toolkits: Vec, +} + +/// Toolkit slugs the memory-sync layer can actually run, sourced from the +/// provider registry (`all_providers()`) — the single source of truth shared +/// with `scan_active_sync_targets`. Exposed so the Add Source picker can +/// disable connections whose toolkit has no provider instead of letting the +/// user add a dead source. See issue #3352. +pub async fn supported_toolkits_rpc() -> Result, String> { + tracing::debug!("[memory_sources] supported_toolkits_rpc: entry"); + // Ensure the built-in providers are registered before we snapshot the + // registry — in CLI / fresh-process contexts the startup hook that calls + // this may not have run yet. + crate::openhuman::memory_sync::composio::init_default_composio_sync_providers(); + + let mut toolkits: Vec = + crate::openhuman::memory_sync::composio::all_composio_sync_providers() + .iter() + .map(|p| p.toolkit_slug().to_string()) + .collect(); + toolkits.sort(); + toolkits.dedup(); + + tracing::debug!( + count = toolkits.len(), + toolkits = ?toolkits, + "[memory_sources] supported_toolkits_rpc: resolved supported toolkit set" + ); + Ok(RpcOutcome::new( + SupportedToolkitsResponse { toolkits }, + vec![], + )) +} + // ── Sync Audit Log ── #[derive(Debug, serde::Serialize)] @@ -498,3 +539,44 @@ pub async fn apply_all_in_rpc() -> Result, String> { vec![], )) } + +#[cfg(test)] +mod supported_toolkits_tests { + use super::*; + + /// The supported-toolkit set must include every built-in provider slug. + /// Asserted via `contains` (not exact equality) because the provider + /// registry is a process-global shared with other tests in this binary + /// that may register ad-hoc dummy providers. + #[tokio::test] + async fn supported_toolkits_includes_builtin_providers() { + let outcome = supported_toolkits_rpc() + .await + .expect("supported_toolkits_rpc should succeed"); + let toolkits = outcome.value.toolkits; + + for slug in ["clickup", "github", "gmail", "linear", "notion", "slack"] { + assert!( + toolkits.iter().any(|t| t == slug), + "expected supported toolkits to include '{slug}', got {toolkits:?}" + ); + } + } + + /// The returned set must be sorted and free of duplicates. + #[tokio::test] + async fn supported_toolkits_is_sorted_and_deduped() { + let outcome = supported_toolkits_rpc() + .await + .expect("supported_toolkits_rpc should succeed"); + let toolkits = outcome.value.toolkits; + + let mut sorted = toolkits.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + toolkits, sorted, + "toolkits should be sorted and de-duplicated" + ); + } +} diff --git a/src/openhuman/memory_sources/schemas.rs b/src/openhuman/memory_sources/schemas.rs index 4e5403275..a35950e69 100644 --- a/src/openhuman/memory_sources/schemas.rs +++ b/src/openhuman/memory_sources/schemas.rs @@ -129,6 +129,7 @@ pub fn all_controller_schemas() -> Vec { schemas("read_item"), schemas("sync"), schemas("status_list"), + schemas("supported_toolkits"), schemas("sync_audit_log"), schemas("estimate_sync_cost"), schemas("monthly_cost_summary"), @@ -174,6 +175,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("status_list"), handler: handle_status_list, }, + RegisteredController { + schema: schemas("supported_toolkits"), + handler: handle_supported_toolkits, + }, RegisteredController { schema: schemas("sync_audit_log"), handler: handle_sync_audit_log, @@ -402,6 +407,19 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "supported_toolkits" => ControllerSchema { + namespace: NAMESPACE, + function: "supported_toolkits", + description: "Toolkit slugs that ship a native memory-sync provider. \ + The Add Source picker disables connections outside this set.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "toolkits", + ty: TypeSchema::Array(Box::new(TypeSchema::String)), + comment: "Sorted, de-duplicated supported toolkit slugs.", + required: true, + }], + }, "sync_audit_log" => ControllerSchema { namespace: NAMESPACE, function: "sync_audit_log", @@ -594,6 +612,10 @@ fn handle_status_list(_params: Map) -> ControllerFuture { Box::pin(async move { to_json(rpc::status_list_rpc().await?) }) } +fn handle_supported_toolkits(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(rpc::supported_toolkits_rpc().await?) }) +} + fn handle_sync_audit_log(_params: Map) -> ControllerFuture { Box::pin(async move { to_json(rpc::sync_audit_log_rpc().await?) }) } diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 9d0de1b3a..a46282e44 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2865,6 +2865,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.memory_sources_read_item", "openhuman.memory_sources_remove", "openhuman.memory_sources_status_list", + "openhuman.memory_sources_supported_toolkits", "openhuman.memory_sources_sync", "openhuman.memory_sources_sync_audit_log", "openhuman.memory_sources_update",