diff --git a/src/components/OrganizationAutocomplete.jsx b/src/components/OrganizationAutocomplete.jsx
new file mode 100644
index 0000000..605e1f1
--- /dev/null
+++ b/src/components/OrganizationAutocomplete.jsx
@@ -0,0 +1,240 @@
+import React, { useState, useEffect, useRef } from 'react';
+import useDebounce from '../hooks/useDebounce';
+import useClickOutside from '../hooks/useClickOutside';
+import { searchOrganizations } from '../services/github';
+import { useApp } from '../context/AppContext';
+import { Spinner } from './UI';
+
+// We use a small in-memory LRU cache specifically for autocomplete to prevent
+// duplicating API requests during rapid typing and to avoid unnecessarily polluting
+// the global IndexedDB cache with partially-typed, short-lived queries.
+const cache = new Map();
+const MAX_SUGGESTIONS = 8;
+const MIN_QUERY_LENGTH = 2;
+
+export default function OrganizationAutocomplete({
+ value,
+ onChange,
+ onKeyDown,
+ onBlur,
+ onSelectOrg,
+ placeholder,
+ style
+}) {
+ const { pat } = useApp();
+ const [suggestions, setSuggestions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [isOpen, setIsOpen] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const [error, setError] = useState(false);
+
+ const containerRef = useRef(null);
+ const abortControllerRef = useRef(null);
+
+ const debouncedValue = useDebounce(value, 400);
+
+ useClickOutside(containerRef, () => {
+ setIsOpen(false);
+ setHighlightedIndex(-1);
+ });
+
+ // Handle query change directly to hide dropdown and show correct states
+ useEffect(() => {
+ if (value.trim().length < MIN_QUERY_LENGTH) {
+ setIsOpen(false);
+ setSuggestions([]);
+ }
+ }, [value]);
+
+ useEffect(() => {
+ const trimmed = debouncedValue.trim();
+ if (trimmed.length < MIN_QUERY_LENGTH) {
+ setSuggestions([]);
+ setIsOpen(false);
+ setLoading(false);
+ return;
+ }
+
+ const fetchOrgs = async () => {
+ setLoading(true);
+ setError(false);
+
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ const cacheKey = trimmed.toLowerCase();
+ if (cache.has(cacheKey)) {
+ setSuggestions(cache.get(cacheKey));
+ setIsOpen(true);
+ setLoading(false);
+ setHighlightedIndex(-1);
+ return;
+ }
+
+ try {
+ const results = await searchOrganizations(trimmed, pat, controller.signal);
+
+ const deduplicated = results.filter((item, index, self) =>
+ index === self.findIndex((t) => t.login.toLowerCase() === item.login.toLowerCase())
+ ).slice(0, MAX_SUGGESTIONS);
+
+ if (cache.size > 100) {
+ const firstKey = cache.keys().next().value;
+ cache.delete(firstKey);
+ }
+ cache.set(cacheKey, deduplicated);
+
+ setSuggestions(deduplicated);
+ setIsOpen(true);
+ setHighlightedIndex(-1);
+ } catch (err) {
+ if (err.name !== 'AbortError') {
+ setError(true);
+ setSuggestions([]);
+ setIsOpen(true);
+ }
+ } finally {
+ if (abortControllerRef.current === controller) {
+ setLoading(false);
+ }
+ }
+ };
+
+ fetchOrgs();
+
+ return () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ };
+ }, [debouncedValue, pat]);
+
+ const handleKeyDown = (e) => {
+ if (!isOpen) {
+ if (onKeyDown) onKeyDown(e);
+ return;
+ }
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setHighlightedIndex(prev => (prev < suggestions.length - 1 ? prev + 1 : 0));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setHighlightedIndex(prev => (prev > 0 ? prev - 1 : suggestions.length - 1));
+ } else if (e.key === 'Enter') {
+ if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) {
+ e.preventDefault();
+ handleSelect(suggestions[highlightedIndex]);
+ } else {
+ setIsOpen(false);
+ if (onKeyDown) onKeyDown(e);
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ setIsOpen(false);
+ setHighlightedIndex(-1);
+ } else {
+ if (onKeyDown) onKeyDown(e);
+ }
+ };
+
+ const handleSelect = (org) => {
+ onSelectOrg(org.login);
+ setIsOpen(false);
+ setHighlightedIndex(-1);
+ };
+
+ const handleBlur = (e) => {
+ if (onBlur) onBlur(e);
+ };
+
+ return (
+
+
= 0 ? `suggestion-${highlightedIndex}` : undefined}
+ />
+
+ {isOpen && value.trim().length >= MIN_QUERY_LENGTH && (
+
+ {loading ? (
+ -
+ Searching...
+
+ ) : error ? (
+ -
+ Failed to load suggestions
+
+ ) : suggestions.length === 0 ? (
+ -
+ No organizations found
+
+ ) : (
+ suggestions.map((org, index) => (
+ - {
+ e.preventDefault(); // Prevent blur
+ handleSelect(org);
+ }}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ style={{
+ padding: '6px 12px',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ background: index === highlightedIndex ? 'var(--surface2)' : 'transparent',
+ color: 'var(--text)',
+ fontSize: 14,
+ }}
+ >
+
+ {org.login}
+
+ ))
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useClickOutside.js b/src/hooks/useClickOutside.js
new file mode 100644
index 0000000..ab525ed
--- /dev/null
+++ b/src/hooks/useClickOutside.js
@@ -0,0 +1,20 @@
+import { useEffect } from 'react';
+
+export default function useClickOutside(ref, handler) {
+ useEffect(() => {
+ const listener = (event) => {
+ if (!ref.current || ref.current.contains(event.target)) {
+ return;
+ }
+ handler(event);
+ };
+
+ document.addEventListener('mousedown', listener);
+ document.addEventListener('touchstart', listener);
+
+ return () => {
+ document.removeEventListener('mousedown', listener);
+ document.removeEventListener('touchstart', listener);
+ };
+ }, [ref, handler]);
+}
diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js
new file mode 100644
index 0000000..3401963
--- /dev/null
+++ b/src/hooks/useDebounce.js
@@ -0,0 +1,17 @@
+import { useState, useEffect } from 'react';
+
+export default function useDebounce(value, delay) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index cffcbd3..152503f 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { FiSearch, FiX } from 'react-icons/fi'
import { useApp } from '../context/AppContext'
import { C, Spinner } from '../components/UI'
+import OrganizationAutocomplete from '../components/OrganizationAutocomplete'
const QUICK = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus']
@@ -74,13 +75,14 @@ export default function HomePage() {
removeChip(c)} />
))}
- setInput(e.target.value)}
onKeyDown={handleKey}
onBlur={() => input.trim() && addChip(input)}
+ onSelectOrg={addChip}
placeholder={chips.length ? 'Add another org...' : 'AOSSIE-Org, StabilityNexus, DjedAlliance...'}
- style={{ flex: 1, minWidth: 160, background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }}
+ style={{ background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }}
/>