rcgit

/ karakeep

Commit ebafbe59

SHA ebafbe599df40c02a0683efc9b424bc8b75af3c3
Author Mohamed Bassem <me at mbassem dot com>
Author Date 2025-11-29 16:31 +0000
Committer GitHub <noreply at github dot com>
Commit Date 2025-11-29 16:31 +0000
Parent(s) 335a84bb5937 (diff)
Tree b60276b97612

patch snapshot

feat: autocomplete search terms (#2178)
* refactor(web): split search autocomplete logic

* some improvements

* restructure the code

* fix typesafety

* add feed suggestions

* fix
File + - Graph
M apps/web/components/dashboard/search/SearchInput.tsx +63 -59
A apps/web/components/dashboard/search/useSearchAutocomplete.ts +555 -0
M apps/web/lib/i18n/locales/en/translation.json +6 -1
M apps/web/lib/i18n/locales/en_US/translation.json +5 -1
4 file(s) changed, 629 insertions(+), 61 deletions(-)

apps/web/components/dashboard/search/SearchInput.tsx

diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index 3f49a53c..644468e8 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -4,7 +4,6 @@ import React, {
   useCallback,
   useEffect,
   useImperativeHandle,
-  useMemo,
   useRef,
   useState,
 } from "react";
@@ -12,6 +11,7 @@ import Link from "next/link";
 import { Button } from "@/components/ui/button";
 import {
   Command,
+  CommandEmpty,
   CommandGroup,
   CommandInput,
   CommandItem,
@@ -25,14 +25,12 @@ import {
 import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
 import { useTranslation } from "@/lib/i18n/client";
 import { cn } from "@/lib/utils";
-import { History } from "lucide-react";
 
 import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
 
 import { EditListModal } from "../lists/EditListModal";
 import QueryExplainerTooltip from "./QueryExplainerTooltip";
-
-const MAX_DISPLAY_SUGGESTIONS = 5;
+import { useSearchAutocomplete } from "./useSearchAutocomplete";
 
 function useFocusSearchOnKeyPress(
   inputRef: React.RefObject<HTMLInputElement | null>,
@@ -85,7 +83,7 @@ const SearchInput = React.forwardRef<
     removeItem: (k: string) => localStorage.removeItem(k),
   });
 
-  const [value, setValue] = React.useState(searchQuery);
+  const [value, setValue] = useState(searchQuery);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
 
@@ -119,19 +117,22 @@ const SearchInput = React.forwardRef<
     [debounceSearch],
   );
 
-  const suggestions = useMemo(() => {
-    if (value.trim() === "") {
-      // Show recent items when not typing
-      return history.slice(0, MAX_DISPLAY_SUGGESTIONS);
-    } else {
-      // Show filtered items when typing
-      return history
-        .filter((item) => item.toLowerCase().includes(value.toLowerCase()))
-        .slice(0, MAX_DISPLAY_SUGGESTIONS);
-    }
-  }, [history, value]);
+  const {
+    suggestionGroups,
+    hasSuggestions,
+    isPopoverVisible,
+    handleSuggestionSelect,
+    handleCommandKeyDown,
+  } = useSearchAutocomplete({
+    value,
+    onValueChange: handleValueChange,
+    inputRef,
+    isPopoverOpen,
+    setIsPopoverOpen,
+    t,
+    history,
+  });
 
-  const isPopoverVisible = isPopoverOpen && suggestions.length > 0;
   const handleHistorySelect = useCallback(
     (term: string) => {
       isHistorySelected.current = true;
@@ -141,28 +142,9 @@ const SearchInput = React.forwardRef<
       setIsPopoverOpen(false);
       inputRef.current?.blur();
     },
-    [doSearch],
+    [doSearch, addTerm],
   );
 
-  const handleCommandKeyDown = useCallback((e: React.KeyboardEvent) => {
-    if (e.key === "Enter") {
-      const selectedItem = document.querySelector(
-        '[cmdk-item][data-selected="true"]',
-      );
-      const isPlaceholderSelected =
-        selectedItem?.getAttribute("data-value") === "-";
-      if (!selectedItem || isPlaceholderSelected) {
-        e.preventDefault();
-        setIsPopoverOpen(false);
-        inputRef.current?.blur();
-      }
-    } else if (e.key === "Escape") {
-      e.preventDefault();
-      setIsPopoverOpen(false);
-      inputRef.current?.blur();
-    }
-  }, []);
-
   useFocusSearchOnKeyPress(inputRef, value, setValue, setIsPopoverOpen);
   useImperativeHandle(ref, () => inputRef.current!);
 
@@ -242,28 +224,50 @@ const SearchInput = React.forwardRef<
             onOpenAutoFocus={(e) => e.preventDefault()}
             onCloseAutoFocus={(e) => e.preventDefault()}
           >
-            <CommandList>
-              <CommandGroup
-                heading={t("search.history")}
-                className="max-h-60 overflow-y-auto"
-              >
-                {/* prevent cmdk auto select the first suggestion -> https://github.com/pacocoursey/cmdk/issues/171*/}
-                <CommandItem value="-" className="hidden" />
-                {suggestions.map((term) => (
-                  <CommandItem
-                    key={term}
-                    value={term}
-                    onSelect={() => handleHistorySelect(term)}
-                    onMouseDown={() => {
-                      isHistorySelected.current = true;
-                    }}
-                    className="cursor-pointer"
-                  >
-                    <History className="mr-2 h-4 w-4" />
-                    <span>{term}</span>
-                  </CommandItem>
-                ))}
-              </CommandGroup>
+            <CommandList className="max-h-96 overflow-y-auto">
+              <CommandEmpty>{t("search.no_suggestions")}</CommandEmpty>
+              {hasSuggestions && <CommandItem value="-" className="hidden" />}
+              {suggestionGroups.map((group) => (
+                <CommandGroup key={group.id} heading={group.label}>
+                  {group.items.map((item) => {
+                    if (item.type === "history") {
+                      return (
+                        <CommandItem
+                          key={item.id}
+                          value={item.label}
+                          onSelect={() => handleHistorySelect(item.term)}
+                          onMouseDown={() => {
+                            isHistorySelected.current = true;
+                          }}
+                          className="cursor-pointer"
+                        >
+                          <item.Icon className="mr-2 h-4 w-4" />
+                          <span>{item.label}</span>
+                        </CommandItem>
+                      );
+                    }
+
+                    return (
+                      <CommandItem
+                        key={item.id}
+                        value={item.label}
+                        onSelect={() => handleSuggestionSelect(item)}
+                        className="cursor-pointer"
+                      >
+                        <item.Icon className="mr-2 h-4 w-4" />
+                        <div className="flex flex-col">
+                          <span>{item.label}</span>
+                          {item.description && (
+                            <span className="text-xs text-muted-foreground">
+                              {item.description}
+                            </span>
+                          )}
+                        </div>
+                      </CommandItem>
+                    );
+                  })}
+                </CommandGroup>
+              ))}
             </CommandList>
           </PopoverContent>
         </Popover>

apps/web/components/dashboard/search/useSearchAutocomplete.ts

diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
new file mode 100644
index 00000000..cce5fc0b
--- /dev/null
+++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
@@ -0,0 +1,555 @@
+import type translation from "@/lib/i18n/locales/en/translation.json";
+import type { TFunction } from "i18next";
+import type { LucideIcon } from "lucide-react";
+import { useCallback, useMemo } from "react";
+import { api } from "@/lib/trpc";
+import {
+  History,
+  ListTree,
+  RssIcon,
+  Sparkles,
+  Tag as TagIcon,
+} from "lucide-react";
+
+import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
+import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags";
+import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
+
+const MAX_DISPLAY_SUGGESTIONS = 5;
+
+type SearchTranslationKey = `search.${keyof typeof translation.search}`;
+
+interface QualifierDefinition {
+  value: string;
+  descriptionKey?: SearchTranslationKey;
+  negatedDescriptionKey?: SearchTranslationKey;
+  appendSpace?: boolean;
+}
+
+const QUALIFIER_DEFINITIONS = [
+  {
+    value: "is:fav",
+    descriptionKey: "search.is_favorited",
+    negatedDescriptionKey: "search.is_not_favorited",
+    appendSpace: true,
+  },
+  {
+    value: "is:archived",
+    descriptionKey: "search.is_archived",
+    negatedDescriptionKey: "search.is_not_archived",
+    appendSpace: true,
+  },
+  {
+    value: "is:tagged",
+    descriptionKey: "search.has_any_tag",
+    negatedDescriptionKey: "search.has_no_tags",
+    appendSpace: true,
+  },
+  {
+    value: "is:inlist",
+    descriptionKey: "search.is_in_any_list",
+    negatedDescriptionKey: "search.is_not_in_any_list",
+    appendSpace: true,
+  },
+  {
+    value: "is:link",
+    appendSpace: true,
+  },
+  {
+    value: "is:text",
+    appendSpace: true,
+  },
+  {
+    value: "is:media",
+    appendSpace: true,
+  },
+  {
+    value: "url:",
+    descriptionKey: "search.url_contains",
+  },
+  {
+    value: "title:",
+    descriptionKey: "search.title_contains",
+  },
+  {
+    value: "list:",
+    descriptionKey: "search.is_in_list",
+  },
+  {
+    value: "after:",
+    descriptionKey: "search.created_on_or_after",
+  },
+  {
+    value: "before:",
+    descriptionKey: "search.created_on_or_before",
+  },
+  {
+    value: "feed:",
+    descriptionKey: "search.is_from_feed",
+  },
+  {
+    value: "age:",
+    descriptionKey: "search.created_within",
+  },
+] satisfies ReadonlyArray<QualifierDefinition>;
+
+export interface AutocompleteSuggestionItem {
+  type: "token" | "tag" | "list" | "feed";
+  id: string;
+  label: string;
+  insertText: string;
+  appendSpace?: boolean;
+  description?: string;
+  Icon: LucideIcon;
+}
+
+export interface HistorySuggestionItem {
+  type: "history";
+  id: string;
+  term: string;
+  label: string;
+  Icon: LucideIcon;
+}
+
+export type SuggestionItem = AutocompleteSuggestionItem | HistorySuggestionItem;
+
+export interface SuggestionGroup {
+  id: string;
+  label: string;
+  items: SuggestionItem[];
+}
+
+const stripSurroundingQuotes = (value: string) => {
+  let nextValue = value;
+  if (nextValue.startsWith('"')) {
+    nextValue = nextValue.slice(1);
+  }
+  if (nextValue.endsWith('"')) {
+    nextValue = nextValue.slice(0, -1);
+  }
+  return nextValue;
+};
+
+const shouldQuoteValue = (value: string) => /[\s:]/.test(value);
+
+const formatSearchValue = (value: string) =>
+  shouldQuoteValue(value) ? `"${value}"` : value;
+
+interface ParsedSearchState {
+  activeToken: string;
+  isTokenNegative: boolean;
+  tokenWithoutMinus: string;
+  normalizedTokenWithoutMinus: string;
+  getActiveToken: (cursorPosition: number) => { token: string; start: number };
+}
+
+interface UseSearchAutocompleteParams {
+  value: string;
+  onValueChange: (value: string) => void;
+  inputRef: React.RefObject<HTMLInputElement | null>;
+  isPopoverOpen: boolean;
+  setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;
+  t: TFunction;
+  history: string[];
+}
+
+const useParsedSearchState = (value: string): ParsedSearchState => {
+  const getActiveToken = useCallback(
+    (cursorPosition: number) => {
+      let start = 0;
+      let inQuotes = false;
+
+      for (let index = 0; index < cursorPosition; index += 1) {
+        const char = value[index];
+        if (char === '"') {
+          inQuotes = !inQuotes;
+          continue;
+        }
+
+        if (!inQuotes) {
+          if (char === " " || char === "\t" || char === "\n") {
+            start = index + 1;
+            continue;
+          }
+
+          if (char === "(") {
+            start = index + 1;
+          }
+        }
+      }
+
+      return {
+        token: value.slice(start, cursorPosition),
+        start,
+      };
+    },
+    [value],
+  );
+
+  const activeTokenInfo = useMemo(
+    () => getActiveToken(value.length),
+    [getActiveToken, value],
+  );
+  const activeToken = activeTokenInfo.token;
+  const isTokenNegative = activeToken.startsWith("-");
+  const tokenWithoutMinus = isTokenNegative
+    ? activeToken.slice(1)
+    : activeToken;
+  const normalizedTokenWithoutMinus = tokenWithoutMinus.toLowerCase();
+
+  return {
+    activeToken,
+    isTokenNegative,
+    tokenWithoutMinus,
+    normalizedTokenWithoutMinus,
+    getActiveToken,
+  };
+};
+
+const useQualifierSuggestions = (
+  parsed: ParsedSearchState,
+  t: TFunction,
+): AutocompleteSuggestionItem[] => {
+  const qualifierSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+    // Don't suggest qualifiers if the user hasn't started typing
+    if (parsed.normalizedTokenWithoutMinus.length === 0) {
+      return [];
+    }
+
+    return QUALIFIER_DEFINITIONS.filter((definition) => {
+      return definition.value
+        .toLowerCase()
+        .startsWith(parsed.normalizedTokenWithoutMinus);
+    })
+      .slice(0, MAX_DISPLAY_SUGGESTIONS)
+      .map((definition) => {
+        const insertText = `${parsed.isTokenNegative ? "-" : ""}${definition.value}`;
+        const descriptionKey = parsed.isTokenNegative
+          ? (definition.negatedDescriptionKey ?? definition.descriptionKey)
+          : definition.descriptionKey;
+        const description = descriptionKey ? t(descriptionKey) : undefined;
+
+        return {
+          type: "token" as const,
+          id: `qualifier-${definition.value}`,
+          label: insertText,
+          insertText,
+          appendSpace: definition.appendSpace,
+          description,
+          Icon: Sparkles,
+        } satisfies AutocompleteSuggestionItem;
+      });
+  }, [parsed.normalizedTokenWithoutMinus, parsed.isTokenNegative, t]);
+
+  return qualifierSuggestions;
+};
+
+const useTagSuggestions = (
+  parsed: ParsedSearchState,
+): AutocompleteSuggestionItem[] => {
+  const shouldSuggestTags = parsed.tokenWithoutMinus.startsWith("#");
+  const tagSearchTermRaw = shouldSuggestTags
+    ? parsed.tokenWithoutMinus.slice(1)
+    : "";
+  const tagSearchTerm = stripSurroundingQuotes(tagSearchTermRaw);
+  const debouncedTagSearchTerm = useDebounce(tagSearchTerm, 200);
+
+  const { data: tagResults } = useTagAutocomplete({
+    nameContains: debouncedTagSearchTerm,
+    select: (data) => data.tags,
+  });
+
+  const tagSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+    if (!shouldSuggestTags) {
+      return [];
+    }
+
+    return (tagResults ?? []).slice(0, MAX_DISPLAY_SUGGESTIONS).map((tag) => {
+      const formattedName = formatSearchValue(tag.name);
+      const insertText = `${parsed.isTokenNegative ? "-" : ""}#${formattedName}`;
+
+      return {
+        type: "tag" as const,
+        id: `tag-${tag.id}`,
+        label: insertText,
+        insertText,
+        appendSpace: true,
+        description: undefined,
+        Icon: TagIcon,
+      } satisfies AutocompleteSuggestionItem;
+    });
+  }, [shouldSuggestTags, tagResults, parsed.isTokenNegative]);
+
+  return tagSuggestions;
+};
+
+const useFeedSuggestions = (
+  parsed: ParsedSearchState,
+): AutocompleteSuggestionItem[] => {
+  const shouldSuggestFeeds =
+    parsed.normalizedTokenWithoutMinus.startsWith("feed:");
+  const feedSearchTermRaw = shouldSuggestFeeds
+    ? parsed.tokenWithoutMinus.slice("feed:".length)
+    : "";
+  const feedSearchTerm = stripSurroundingQuotes(feedSearchTermRaw);
+  const normalizedFeedSearchTerm = feedSearchTerm.toLowerCase();
+  const { data: feedResults } = api.feeds.list.useQuery();
+
+  const feedSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+    if (!shouldSuggestFeeds) {
+      return [];
+    }
+
+    const feeds = feedResults?.feeds ?? [];
+
+    return feeds
+      .filter((feed) => {
+        if (normalizedFeedSearchTerm.length === 0) {
+          return true;
+        }
+        return feed.name.toLowerCase().includes(normalizedFeedSearchTerm);
+      })
+      .slice(0, MAX_DISPLAY_SUGGESTIONS)
+      .map((feed) => {
+        const formattedName = formatSearchValue(feed.name);
+        const insertText = `${parsed.isTokenNegative ? "-" : ""}feed:${formattedName}`;
+        return {
+          type: "feed" as const,
+          id: `feed-${feed.id}`,
+          label: insertText,
+          insertText,
+          appendSpace: true,
+          description: undefined,
+          Icon: RssIcon,
+        } satisfies AutocompleteSuggestionItem;
+      });
+  }, [
+    shouldSuggestFeeds,
+    feedResults,
+    normalizedFeedSearchTerm,
+    parsed.isTokenNegative,
+  ]);
+
+  return feedSuggestions;
+};
+
+const useListSuggestions = (
+  parsed: ParsedSearchState,
+): AutocompleteSuggestionItem[] => {
+  const shouldSuggestLists =
+    parsed.normalizedTokenWithoutMinus.startsWith("list:");
+  const listSearchTermRaw = shouldSuggestLists
+    ? parsed.tokenWithoutMinus.slice("list:".length)
+    : "";
+  const listSearchTerm = stripSurroundingQuotes(listSearchTermRaw);
+  const normalizedListSearchTerm = listSearchTerm.toLowerCase();
+  const { data: listResults } = useBookmarkLists();
+
+  const listSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+    if (!shouldSuggestLists) {
+      return [];
+    }
+
+    const lists = listResults?.data ?? [];
+
+    return lists
+      .filter((list) => {
+        if (normalizedListSearchTerm.length === 0) {
+          return true;
+        }
+        return list.name.toLowerCase().includes(normalizedListSearchTerm);
+      })
+      .slice(0, MAX_DISPLAY_SUGGESTIONS)
+      .map((list) => {
+        const formattedName = formatSearchValue(list.name);
+        const insertText = `${parsed.isTokenNegative ? "-" : ""}list:${formattedName}`;
+        return {
+          type: "list" as const,
+          id: `list-${list.id}`,
+          label: insertText,
+          insertText,
+          appendSpace: true,
+          description: undefined,
+          Icon: ListTree,
+        } satisfies AutocompleteSuggestionItem;
+      });
+  }, [
+    shouldSuggestLists,
+    listResults,
+    normalizedListSearchTerm,
+    parsed.isTokenNegative,
+  ]);
+
+  return listSuggestions;
+};
+
+const useHistorySuggestions = (
+  value: string,
+  history: string[],
+): HistorySuggestionItem[] => {
+  const historyItems = useMemo<HistorySuggestionItem[]>(() => {
+    const trimmedValue = value.trim();
+    const results =
+      trimmedValue.length === 0
+        ? history
+        : history.filter((item) =>
+            item.toLowerCase().includes(trimmedValue.toLowerCase()),
+          );
+
+    return results.slice(0, MAX_DISPLAY_SUGGESTIONS).map(
+      (term) =>
+        ({
+          type: "history" as const,
+          id: `history-${term}`,
+          term,
+          label: term,
+          Icon: History,
+        }) satisfies HistorySuggestionItem,
+    );
+  }, [history, value]);
+
+  return historyItems;
+};
+
+export const useSearchAutocomplete = ({
+  value,
+  onValueChange,
+  inputRef,
+  isPopoverOpen,
+  setIsPopoverOpen,
+  t,
+  history,
+}: UseSearchAutocompleteParams) => {
+  const parsedState = useParsedSearchState(value);
+  const qualifierSuggestions = useQualifierSuggestions(parsedState, t);
+  const tagSuggestions = useTagSuggestions(parsedState);
+  const listSuggestions = useListSuggestions(parsedState);
+  const feedSuggestions = useFeedSuggestions(parsedState);
+  const historyItems = useHistorySuggestions(value, history);
+  const { activeToken, getActiveToken } = parsedState;
+
+  const suggestionGroups = useMemo<SuggestionGroup[]>(() => {
+    const groups: SuggestionGroup[] = [];
+
+    if (tagSuggestions.length > 0) {
+      groups.push({
+        id: "tags",
+        label: t("search.tags"),
+        items: tagSuggestions,
+      });
+    }
+
+    if (listSuggestions.length > 0) {
+      groups.push({
+        id: "lists",
+        label: t("search.lists"),
+        items: listSuggestions,
+      });
+    }
+
+    if (feedSuggestions.length > 0) {
+      groups.push({
+        id: "feeds",
+        label: t("search.feeds"),
+        items: feedSuggestions,
+      });
+    }
+
+    // Only suggest qualifiers if no other suggestions are available
+    if (groups.length === 0 && qualifierSuggestions.length > 0) {
+      groups.push({
+        id: "qualifiers",
+        label: t("search.filters"),
+        items: qualifierSuggestions,
+      });
+    }
+
+    if (historyItems.length > 0) {
+      groups.push({
+        id: "history",
+        label: t("search.history"),
+        items: historyItems,
+      });
+    }
+
+    return groups;
+  }, [
+    qualifierSuggestions,
+    tagSuggestions,
+    listSuggestions,
+    feedSuggestions,
+    historyItems,
+    t,
+  ]);
+
+  const hasSuggestions = suggestionGroups.length > 0;
+  const showEmptyState =
+    isPopoverOpen && !hasSuggestions && activeToken.length > 0;
+  const isPopoverVisible = isPopoverOpen && (hasSuggestions || showEmptyState);
+
+  const handleSuggestionSelect = useCallback(
+    (item: AutocompleteSuggestionItem) => {
+      const input = inputRef.current;
+      const selectionStart = input?.selectionStart ?? value.length;
+      const selectionEnd = input?.selectionEnd ?? selectionStart;
+      const { start } = getActiveToken(selectionStart);
+      const beforeToken = value.slice(0, start);
+      const afterToken = value.slice(selectionEnd);
+
+      const needsSpace =
+        item.appendSpace &&
+        (afterToken.length === 0 || !/^\s/.test(afterToken));
+      const baseValue = `${beforeToken}${item.insertText}${afterToken}`;
+      const finalValue = needsSpace
+        ? `${beforeToken}${item.insertText} ${afterToken}`
+        : baseValue;
+
+      onValueChange(finalValue);
+
+      requestAnimationFrame(() => {
+        const target = inputRef.current;
+        if (!target) {
+          return;
+        }
+        const cursorPosition =
+          beforeToken.length + item.insertText.length + (needsSpace ? 1 : 0);
+        target.focus();
+        target.setSelectionRange(cursorPosition, cursorPosition);
+      });
+
+      setIsPopoverOpen(true);
+    },
+    [getActiveToken, onValueChange, value, inputRef, setIsPopoverOpen],
+  );
+
+  const handleCommandKeyDown = useCallback(
+    (e: React.KeyboardEvent) => {
+      if (e.key === "Enter") {
+        const selectedItem = document.querySelector(
+          '[cmdk-item][data-selected="true"]',
+        );
+        const isPlaceholderSelected =
+          selectedItem?.getAttribute("data-value") === "-";
+        if (!selectedItem || isPlaceholderSelected) {
+          e.preventDefault();
+          setIsPopoverOpen(false);
+          inputRef.current?.blur();
+        }
+      } else if (e.key === "Escape") {
+        e.preventDefault();
+        setIsPopoverOpen(false);
+        inputRef.current?.blur();
+      }
+    },
+    [setIsPopoverOpen, inputRef],
+  );
+
+  return {
+    suggestionGroups,
+    hasSuggestions,
+    showEmptyState,
+    isPopoverVisible,
+    handleSuggestionSelect,
+    handleCommandKeyDown,
+  };
+};

apps/web/lib/i18n/locales/en/translation.json

diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 43d45cb5..ccd80ba2 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -664,7 +664,12 @@
     "is_not_from_feed": "Is not from RSS Feed",
     "and": "And",
     "or": "Or",
-    "history": "Recent Searches"
+    "history": "Recent Searches",
+    "filters": "Filters",
+    "tags": "Tags",
+    "lists": "Lists",
+    "feeds": "Feeds",
+    "no_suggestions": "No suggestions"
   },
   "preview": {
     "view_original": "View Original",

apps/web/lib/i18n/locales/en_US/translation.json

diff --git a/apps/web/lib/i18n/locales/en_US/translation.json b/apps/web/lib/i18n/locales/en_US/translation.json
index 909941ac..5c4d2b62 100644
--- a/apps/web/lib/i18n/locales/en_US/translation.json
+++ b/apps/web/lib/i18n/locales/en_US/translation.json
@@ -646,7 +646,11 @@
     "is_not_from_feed": "Is not from RSS feed",
     "and": "And",
     "or": "Or",
-    "history": "Recent Searches"
+    "history": "Recent Searches",
+    "filters": "Filters",
+    "tags": "Tags",
+    "lists": "Lists",
+    "no_suggestions": "No suggestions"
   },
   "preview": {
     "view_original": "View Original",