diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 7b02c5d..416217d 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -1811,6 +1811,162 @@ function getFilterKeys(filter: unknown): string[] {
return [];
}
+const MAX_SCOPE_PILL_DISPLAY = 32;
+
+interface PillsSearchListItem {
+ key: string;
+ label: string;
+ /** Optional trailing monospace hint (e.g. pubkey prefix) */
+ trailing?: string;
+}
+
+/**
+ * Search-and-pills picker for the generic fanout scope selector.
+ * Shows selected items as removable pills (up to MAX_SCOPE_PILL_DISPLAY),
+ * a search input, and a scrollable list of filtered items with checkboxes.
+ * When more than MAX_SCOPE_PILL_DISPLAY items are selected, the pill row
+ * collapses to a single informational badge to keep the interface clean.
+ */
+function PillsSearchList({
+ label,
+ labelSuffix,
+ items,
+ selectedKeys,
+ onToggle,
+ onAll,
+ onNone,
+ searchPlaceholder,
+ emptyItemsMessage,
+}: {
+ label: string;
+ labelSuffix: string;
+ items: PillsSearchListItem[];
+ selectedKeys: string[];
+ onToggle: (key: string) => void;
+ onAll: () => void;
+ onNone: () => void;
+ searchPlaceholder: string;
+ emptyItemsMessage: string;
+}) {
+ const [search, setSearch] = useState('');
+ const searchLower = search.toLowerCase().trim();
+
+ const filtered = useMemo(() => {
+ const matches = items.filter((it) => {
+ if (!searchLower) return true;
+ return (
+ it.label.toLowerCase().includes(searchLower) || it.key.toLowerCase().startsWith(searchLower)
+ );
+ });
+ // Selected items sort to top (mirrors the Home Assistant tracked-contacts picker)
+ return matches.sort((a, b) => {
+ const aSel = selectedKeys.includes(a.key) ? 0 : 1;
+ const bSel = selectedKeys.includes(b.key) ? 0 : 1;
+ if (aSel !== bSel) return aSel - bSel;
+ return a.label.localeCompare(b.label);
+ });
+ }, [items, searchLower, selectedKeys]);
+
+ const selectedDetails = useMemo(
+ () => items.filter((it) => selectedKeys.includes(it.key)),
+ [items, selectedKeys]
+ );
+ const overPillLimit = selectedDetails.length > MAX_SCOPE_PILL_DISPLAY;
+
+ return (
+
+
+
+
+
+ /
+
+
+
+
+ {selectedDetails.length > 0 && (
+
+ {overPillLimit ? (
+
+ >{MAX_SCOPE_PILL_DISPLAY} selections made; hiding selection preview to keep the
+ interface clean
+
+ ) : (
+ selectedDetails.map((it) => (
+
+ {it.label}
+
+
+ ))
+ )}
+
+ )}
+
+ {items.length === 0 ? (
+
{emptyItemsMessage}
+ ) : (
+ <>
+
setSearch(e.target.value)}
+ className="h-8 text-sm"
+ />
+
+ {filtered.length === 0 ? (
+
+ No {label.toLowerCase()} match “{search}”
+
+ ) : (
+ filtered.map((it) => (
+
+ ))
+ )}
+
+ >
+ )}
+
+ );
+}
+
function ScopeSelector({
scope,
onChange,
@@ -1920,9 +2076,6 @@ function ScopeSelector({
selectedContacts.length >= filteredContacts.length);
const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled;
- const isChannelChecked = (key: string) => selectedChannels.includes(key);
- const isContactChecked = (key: string) => selectedContacts.includes(key);
-
const listHint =
mode === 'only'
? 'Newly added channels or contacts will not be automatically included.'
@@ -1976,107 +2129,51 @@ function ScopeSelector({
{listHint}
{channels.length > 0 && (
-
-
-
-
-
- /
-
-
-
-
- {channels.map((ch) => (
-
- ))}
-
-
+ ({ key: ch.key, label: ch.name }))}
+ selectedKeys={selectedChannels}
+ onToggle={toggleChannel}
+ onAll={() =>
+ onChange({
+ ...scope,
+ messages: buildMessages(
+ channels.map((ch) => ch.key),
+ selectedContacts
+ ),
+ })
+ }
+ onNone={() => onChange({ ...scope, messages: buildMessages([], selectedContacts) })}
+ searchPlaceholder={`Search ${channels.length} channel${channels.length === 1 ? '' : 's'}...`}
+ emptyItemsMessage="No channels available."
+ />
)}
{filteredContacts.length > 0 && (
-
-
-
-
-
- /
-
-
-
-
- {filteredContacts.map((c) => (
-
- ))}
-
-
+ ({
+ key: c.public_key,
+ label: c.name || c.public_key.slice(0, 12),
+ trailing: c.public_key.slice(0, 12),
+ }))}
+ selectedKeys={selectedContacts}
+ onToggle={toggleContact}
+ onAll={() =>
+ onChange({
+ ...scope,
+ messages: buildMessages(
+ selectedChannels,
+ filteredContacts.map((c) => c.public_key)
+ ),
+ })
+ }
+ onNone={() => onChange({ ...scope, messages: buildMessages(selectedChannels, []) })}
+ searchPlaceholder={`Search ${filteredContacts.length} contact${filteredContacts.length === 1 ? '' : 's'}...`}
+ emptyItemsMessage="No contacts available."
+ />
)}
>
)}