mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory-sources): disable non-syncable toolkits in Add Source picker (#3395)
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
const mockGetSupportedToolkits = getSupportedToolkits as ReturnType<typeof vi.fn>;
|
||||
|
||||
/** 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ComposioConnection[]>([]);
|
||||
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<string[] | null>(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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const listboxRef = useRef<HTMLUListElement>(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<PickerEntry[]>(() => {
|
||||
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 <select>'s disabled opts.
|
||||
const selectableIndexes = useMemo(
|
||||
() => entries.map((e, i) => (e.supported ? i : -1)).filter(i => i >= 0),
|
||||
[entries]
|
||||
);
|
||||
|
||||
const selected = entries.find(e => e.conn.id === connectionId) ?? null;
|
||||
|
||||
// Close the popover on outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Move keyboard focus into the listbox when it opens so arrow keys work
|
||||
// immediately. This is a DOM side-effect only — the highlighted index is set
|
||||
// in the open/close handlers, not here, to avoid setState-in-effect churn.
|
||||
useEffect(() => {
|
||||
if (open) listboxRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
if (loadingConnections) {
|
||||
return (
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
@@ -584,30 +675,177 @@ function ComposioPicker({
|
||||
);
|
||||
}
|
||||
|
||||
// Highlight the current selection (or first selectable option) and open.
|
||||
const openListbox = () => {
|
||||
const selIdx = entries.findIndex(e => e.conn.id === connectionId && e.supported);
|
||||
setActiveIndex(selIdx >= 0 ? selIdx : (selectableIndexes[0] ?? -1));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const close = (returnFocus = true) => {
|
||||
setActiveIndex(-1);
|
||||
setOpen(false);
|
||||
if (returnFocus) buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const select = (entry: PickerEntry) => {
|
||||
if (!entry.supported) {
|
||||
log('[composio-picker] ignoring selection of unsupported toolkit=%s', entry.conn.toolkit);
|
||||
return;
|
||||
}
|
||||
setConnection(entry.conn.id, entry.conn.toolkit, entry.label);
|
||||
close();
|
||||
};
|
||||
|
||||
// Move the highlight to the next/previous selectable option, wrapping around.
|
||||
const moveActive = (dir: 1 | -1) => {
|
||||
if (selectableIndexes.length === 0) return;
|
||||
const pos = selectableIndexes.indexOf(activeIndex);
|
||||
const nextPos =
|
||||
pos === -1
|
||||
? dir === 1
|
||||
? 0
|
||||
: selectableIndexes.length - 1
|
||||
: (pos + dir + selectableIndexes.length) % selectableIndexes.length;
|
||||
setActiveIndex(selectableIndexes[nextPos]);
|
||||
};
|
||||
|
||||
const onButtonKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
// Open with the arrow keys; Enter/Space already toggle via onClick.
|
||||
if (!open && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
||||
event.preventDefault();
|
||||
openListbox();
|
||||
}
|
||||
};
|
||||
|
||||
const onListKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveActive(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveActive(-1);
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
if (selectableIndexes.length) setActiveIndex(selectableIndexes[0]);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
if (selectableIndexes.length)
|
||||
setActiveIndex(selectableIndexes[selectableIndexes.length - 1]);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0 && entries[activeIndex]) select(entries[activeIndex]);
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
close();
|
||||
break;
|
||||
case 'Tab':
|
||||
// Let focus leave naturally, but collapse the popover.
|
||||
setOpen(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const LISTBOX_ID = 'composio-connection-listbox';
|
||||
const optionId = (entry: PickerEntry) => `composio-opt-${entry.conn.id}`;
|
||||
const activeOptionId =
|
||||
activeIndex >= 0 && entries[activeIndex] ? optionId(entries[activeIndex]) : undefined;
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="block" ref={containerRef}>
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-400">
|
||||
{t('memorySources.pickConnection')}
|
||||
</span>
|
||||
<select
|
||||
value={connectionId}
|
||||
onChange={e => {
|
||||
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">
|
||||
<option value="">{t('memorySources.selectConnection')}</option>
|
||||
{dedupedConnections.map(({ conn, label }) => (
|
||||
<option key={conn.id} value={conn.id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
data-testid="composio-connection-picker"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? LISTBOX_ID : undefined}
|
||||
onClick={() => (open ? close(false) : openListbox())}
|
||||
onKeyDown={onButtonKeyDown}
|
||||
className="flex w-full items-center justify-between rounded-md border border-stone-300
|
||||
bg-white px-3 py-2 text-left 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">
|
||||
<span className={selected ? '' : 'text-stone-400 dark:text-neutral-500'}>
|
||||
{selected ? selected.label : t('memorySources.selectConnection')}
|
||||
</span>
|
||||
<span aria-hidden className="ml-2 text-stone-400 dark:text-neutral-500">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
ref={listboxRef}
|
||||
id={LISTBOX_ID}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={t('memorySources.pickConnection')}
|
||||
aria-activedescendant={activeOptionId}
|
||||
onKeyDown={onListKeyDown}
|
||||
data-testid="composio-connection-listbox"
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border
|
||||
border-stone-200 bg-white py-1 shadow-lg focus:outline-none
|
||||
dark:border-neutral-700 dark:bg-neutral-800">
|
||||
{entries.map((entry, index) => {
|
||||
const isSelected = entry.conn.id === connectionId;
|
||||
const isActive = index === activeIndex;
|
||||
return (
|
||||
<li
|
||||
key={entry.conn.id}
|
||||
id={optionId(entry)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={!entry.supported}
|
||||
data-testid={`composio-option-${entry.conn.id}`}
|
||||
data-supported={entry.supported}
|
||||
data-active={isActive}
|
||||
onClick={() => 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(' ')}>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{isSelected && entry.supported && (
|
||||
<span aria-hidden className="text-primary-500">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{entry.label}</span>
|
||||
</span>
|
||||
{!entry.supported && (
|
||||
<span
|
||||
data-testid={`composio-option-coming-soon-${entry.conn.id}`}
|
||||
className="shrink-0 rounded-full bg-stone-100 px-2 py-0.5 text-[10px]
|
||||
font-medium uppercase tracking-wide text-stone-500
|
||||
dark:bg-neutral-700 dark:text-neutral-400">
|
||||
{t('memorySources.comingSoon')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '🔗',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -166,6 +166,21 @@ export async function syncMemorySource(sourceId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string[]> {
|
||||
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;
|
||||
|
||||
@@ -314,6 +314,47 @@ pub async fn status_list_rpc() -> Result<RpcOutcome<StatusListResponse>, 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<String>,
|
||||
}
|
||||
|
||||
/// 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<RpcOutcome<SupportedToolkitsResponse>, 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<String> =
|
||||
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<RpcOutcome<AllInResponse>, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
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<RegisteredController> {
|
||||
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<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::status_list_rpc().await?) })
|
||||
}
|
||||
|
||||
fn handle_supported_toolkits(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::supported_toolkits_rpc().await?) })
|
||||
}
|
||||
|
||||
fn handle_sync_audit_log(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::sync_audit_log_rpc().await?) })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user