mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
839 lines
28 KiB
TypeScript
839 lines
28 KiB
TypeScript
/**
|
|
* 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<SourceKind | null>(null);
|
|
const [label, setLabel] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<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 + 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<string, unknown> = { 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<MemorySourceEntry, 'id'>);
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
<div className="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-xl">
|
|
<h2 className="text-lg font-semibold text-content">{t('memorySources.addSource')}</h2>
|
|
|
|
{!kind ? (
|
|
<>
|
|
<p className="mt-2 text-sm text-content-muted">{t('memorySources.pickKind')}</p>
|
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
|
{ALL_KINDS.map(k => (
|
|
<button
|
|
key={k}
|
|
type="button"
|
|
onClick={() => setKind(k)}
|
|
className="flex items-center gap-3 rounded-lg border border-line p-3
|
|
text-left transition-colors hover:border-primary-400 hover:bg-primary-50
|
|
dark:border-line-strong dark:hover:border-primary-500 dark:hover:bg-primary-500/10">
|
|
<span className="text-xl">{SOURCE_KIND_ICONS[k]}</span>
|
|
<span className="text-sm font-medium text-content">
|
|
{t(SOURCE_KIND_LABEL_KEYS[k])}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 flex justify-end">
|
|
<Button variant="tertiary" size="md" onClick={handleClose}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="mt-1 text-sm text-content-muted">
|
|
{SOURCE_KIND_ICONS[kind]} {t(SOURCE_KIND_LABEL_KEYS[kind])}
|
|
</p>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
<Field
|
|
label={t('memorySources.label')}
|
|
value={label}
|
|
onChange={setLabel}
|
|
placeholder={t('memorySources.labelPlaceholder')}
|
|
/>
|
|
<KindFields
|
|
kind={kind}
|
|
path={path}
|
|
setPath={setPath}
|
|
glob={glob}
|
|
setGlob={setGlob}
|
|
url={url}
|
|
setUrl={setUrl}
|
|
branch={branch}
|
|
setBranch={setBranch}
|
|
query={query}
|
|
setQuery={setQuery}
|
|
selector={selector}
|
|
setSelector={setSelector}
|
|
connections={connections}
|
|
loadingConnections={loadingConnections}
|
|
supportedToolkits={supportedToolkits}
|
|
connectionId={connectionId}
|
|
setConnection={(connId, tk, identityLabel) => {
|
|
setConnectionId(connId);
|
|
setToolkit(tk);
|
|
if (!label) setLabel(identityLabel);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="mt-3 rounded-md bg-coral-50 p-2 text-xs text-coral-800 dark:bg-coral-500/10 dark:text-coral-300">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-5 flex items-center justify-between">
|
|
<Button
|
|
variant="tertiary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setKind(null);
|
|
setError(null);
|
|
}}>
|
|
← {t('memorySources.backToKinds')}
|
|
</Button>
|
|
<div className="flex gap-2">
|
|
<Button variant="tertiary" size="md" onClick={handleClose}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
onClick={handleSubmit}
|
|
disabled={!isValid || submitting}>
|
|
{submitting ? t('memorySources.adding') : t('memorySources.add')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-content-secondary">{label}</span>
|
|
<div className="mt-1 flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={t('memorySources.folderPathPlaceholder')}
|
|
className="block w-full rounded-md border border-line-strong bg-surface px-3 py-2
|
|
text-sm text-content placeholder-content-faint
|
|
focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400
|
|
dark:border-neutral-600 dark:bg-surface-muted dark:text-content
|
|
dark:placeholder-content-faint dark:focus:border-primary-500"
|
|
/>
|
|
<label
|
|
className="shrink-0 cursor-pointer rounded-md border border-line-strong bg-surface px-3 py-2
|
|
text-xs font-medium text-content-secondary transition-colors
|
|
hover:border-primary-400 hover:text-primary-600
|
|
dark:border-neutral-600 dark:bg-surface-muted dark:text-content-secondary
|
|
dark:hover:border-primary-500 dark:hover:text-primary-400">
|
|
{t('memorySources.browse')}
|
|
<input
|
|
type="file"
|
|
// @ts-expect-error — non-standard but supported in CEF/Chromium
|
|
webkitdirectory=""
|
|
multiple
|
|
className="hidden"
|
|
onChange={e => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
// Chromium exposes the chosen directory path on the first file's `path`
|
|
// attribute when the renderer has filesystem-aware integration (CEF).
|
|
// Fall back to webkitRelativePath split if `path` isn't available.
|
|
const first = files[0] as File & { path?: string };
|
|
if (first.path) {
|
|
// first.path is the absolute path to the file. Derive the directory
|
|
// by trimming the relative portion (everything after the chosen root).
|
|
const rel = first.webkitRelativePath || first.name;
|
|
const abs = first.path;
|
|
const idx = abs.lastIndexOf(rel);
|
|
onChange(idx > 0 ? abs.slice(0, idx).replace(/\/$/, '') : abs);
|
|
} else if (first.webkitRelativePath) {
|
|
onChange(first.webkitRelativePath.split('/')[0]);
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value, onChange, placeholder, type = 'text' }: FieldProps) {
|
|
return (
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-content-secondary">{label}</span>
|
|
<input
|
|
type={type}
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="mt-1 block w-full rounded-md border border-line-strong bg-surface px-3 py-2
|
|
text-sm text-content placeholder-content-faint
|
|
focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400
|
|
dark:border-neutral-600 dark:bg-surface-muted dark:text-content
|
|
dark:placeholder-content-faint dark:focus:border-primary-500"
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
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 <ComposioPicker {...props} />;
|
|
case 'conversation':
|
|
return null;
|
|
case 'folder':
|
|
return (
|
|
<>
|
|
<FolderField
|
|
label={t('memorySources.folderPath')}
|
|
value={props.path}
|
|
onChange={props.setPath}
|
|
/>
|
|
<Field
|
|
label={t('memorySources.globPattern')}
|
|
value={props.glob}
|
|
onChange={props.setGlob}
|
|
placeholder={t('memorySources.globPatternPlaceholder')}
|
|
/>
|
|
</>
|
|
);
|
|
case 'github_repo':
|
|
return (
|
|
<>
|
|
<Field
|
|
label={t('memorySources.repoUrl')}
|
|
value={props.url}
|
|
onChange={props.setUrl}
|
|
placeholder={t('memorySources.repoUrlPlaceholder')}
|
|
/>
|
|
<Field
|
|
label={t('memorySources.branch')}
|
|
value={props.branch}
|
|
onChange={props.setBranch}
|
|
placeholder={t('memorySources.branchPlaceholder')}
|
|
/>
|
|
</>
|
|
);
|
|
case 'rss_feed':
|
|
return (
|
|
<Field
|
|
label={t('memorySources.feedUrl')}
|
|
value={props.url}
|
|
onChange={props.setUrl}
|
|
placeholder={t('memorySources.feedUrlPlaceholder')}
|
|
/>
|
|
);
|
|
case 'web_page':
|
|
return (
|
|
<>
|
|
<Field
|
|
label={t('memorySources.pageUrl')}
|
|
value={props.url}
|
|
onChange={props.setUrl}
|
|
placeholder={t('memorySources.pageUrlPlaceholder')}
|
|
/>
|
|
<Field
|
|
label={t('memorySources.cssSelector')}
|
|
value={props.selector}
|
|
onChange={props.setSelector}
|
|
placeholder={t('memorySources.cssSelectorPlaceholder')}
|
|
/>
|
|
</>
|
|
);
|
|
case 'twitter_query':
|
|
return (
|
|
<Field
|
|
label={t('memorySources.searchQuery')}
|
|
value={props.query}
|
|
onChange={props.setQuery}
|
|
placeholder={t('memorySources.searchQueryPlaceholder')}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Active-first status rank — lower is better. */
|
|
const STATUS_RANK: Record<string, number> = {
|
|
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<string>();
|
|
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<HTMLDivElement>(null);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const listboxRef = useRef<HTMLUListElement>(null);
|
|
|
|
// useMemo must be declared before any early returns (Rules of Hooks).
|
|
const entries = useMemo<PickerEntry[]>(() => {
|
|
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 <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-content-muted">{t('memorySources.loadingConnections')}</p>;
|
|
}
|
|
|
|
if (connections.length === 0) {
|
|
return (
|
|
<p className="rounded-md bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-500/10 dark:text-amber-300">
|
|
{t('memorySources.noConnections')}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="block" ref={containerRef}>
|
|
<span className="text-xs font-medium text-content-secondary">
|
|
{t('memorySources.pickConnection')}
|
|
</span>
|
|
<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-line-strong
|
|
bg-surface px-3 py-2 text-left text-sm text-content
|
|
focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400
|
|
dark:border-neutral-600 dark:bg-surface-muted dark:text-content
|
|
dark:focus:border-primary-500">
|
|
<span className={selected ? '' : 'text-content-faint'}>
|
|
{selected ? selected.label : t('memorySources.selectConnection')}
|
|
</span>
|
|
<span aria-hidden className="ml-2 text-content-faint">
|
|
▾
|
|
</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-line bg-surface py-1 shadow-lg focus:outline-none
|
|
dark:border-line-strong dark:bg-surface-muted">
|
|
{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-content'
|
|
: 'cursor-not-allowed text-content-faint',
|
|
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-surface-subtle px-2 py-0.5 text-[10px]
|
|
font-medium uppercase tracking-wide text-content-muted
|
|
dark:bg-neutral-700 dark:text-content-muted">
|
|
{t('memorySources.comingSoon')}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|