diff --git a/gui/src/components/mainInput/Lump/sections/docs/DocsSection.test.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.test.tsx
new file mode 100644
index 0000000000..8b9731037e
--- /dev/null
+++ b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.test.tsx
@@ -0,0 +1,242 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { Provider } from "react-redux";
+import { configureStore } from "@reduxjs/toolkit";
+import DocsIndexingStatuses from "./DocsSection";
+import { AuthProvider } from "../../../../../context/Auth";
+import { vi } from "vitest";
+
+// Mock the dependencies
+vi.mock("../ExploreBlocksButton", () => ({
+ ExploreBlocksButton: () =>
Explore Blocks
,
+}));
+
+vi.mock("./DocsIndexingStatus", () => ({
+ default: ({ docConfig }: any) => (
+ {docConfig.title || docConfig.startUrl}
+ ),
+}));
+
+vi.mock("../../../../Input", () => ({
+ Input: ({ onChange, value, placeholder, ...props }: any) => (
+
+ ),
+}));
+
+vi.mock("../../../../Select", () => ({
+ Select: ({ children, value, onValueChange }: any) => (
+
+ {children}
+
+ ),
+ SelectTrigger: ({ children }: any) => (
+
+ ),
+ SelectContent: ({ children }: any) => {children}
,
+ SelectItem: ({ children, value }: any) => (
+
+ ),
+ SelectValue: ({ placeholder }: any) => {placeholder},
+}));
+
+const createMockStore = (docs: any[] = [], statuses: any = {}) => {
+ return configureStore({
+ reducer: {
+ config: (state = { config: { docs } }) => state,
+ indexing: (state = { indexing: { statuses } }) => state,
+ },
+ });
+};
+
+const renderWithProviders = (component: JSX.Element, store: any) => {
+ return render(
+
+
+ {component}
+
+
+ );
+};
+
+describe("DocsIndexingStatuses", () => {
+ it("renders the search input and filter controls", () => {
+ const store = createMockStore();
+ renderWithProviders(, store);
+
+ expect(screen.getByTestId("search-input")).toBeInTheDocument();
+ expect(screen.getAllByTestId("select-trigger")).toHaveLength(2); // Sort and Group selects
+ });
+
+ it("filters docs based on search query", async () => {
+ const docs = [
+ { title: "React Documentation", startUrl: "https://react.dev" },
+ { title: "Vue Guide", startUrl: "https://vuejs.org" },
+ { title: "Angular Docs", startUrl: "https://angular.io" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ const searchInput = screen.getByTestId("search-input");
+ fireEvent.change(searchInput, { target: { value: "React" } });
+
+ await waitFor(() => {
+ const docItems = screen.getAllByTestId("doc-item");
+ expect(docItems).toHaveLength(1);
+ expect(docItems[0]).toHaveTextContent("React Documentation");
+ });
+ });
+
+ it("shows empty state when no docs match search", async () => {
+ const docs = [
+ { title: "React Documentation", startUrl: "https://react.dev" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ const searchInput = screen.getByTestId("search-input");
+ fireEvent.change(searchInput, { target: { value: "NonExistent" } });
+
+ await waitFor(() => {
+ expect(screen.getByText(/No documentation found matching/)).toBeInTheDocument();
+ });
+ });
+
+ it("clears search when clear button is clicked", async () => {
+ const docs = [
+ { title: "React Documentation", startUrl: "https://react.dev" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ const searchInput = screen.getByTestId("search-input");
+ fireEvent.change(searchInput, { target: { value: "test" } });
+
+ await waitFor(() => {
+ expect(screen.getByText("Clear search")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText("Clear search"));
+ expect(searchInput).toHaveValue("");
+ });
+
+ it("sorts docs by status correctly", () => {
+ const docs = [
+ { title: "Doc 1", startUrl: "https://doc1.com" },
+ { title: "Doc 2", startUrl: "https://doc2.com" },
+ { title: "Doc 3", startUrl: "https://doc3.com" },
+ ];
+ const statuses = {
+ "https://doc1.com": { status: "complete" },
+ "https://doc2.com": { status: "failed" },
+ "https://doc3.com": { status: "indexing" },
+ };
+ const store = createMockStore(docs, statuses);
+ renderWithProviders(, store);
+
+ const docItems = screen.getAllByTestId("doc-item");
+ // Should be sorted: indexing, failed, complete
+ expect(docItems[0]).toHaveTextContent("Doc 3");
+ expect(docItems[1]).toHaveTextContent("Doc 2");
+ expect(docItems[2]).toHaveTextContent("Doc 1");
+ });
+
+ it("groups docs by domain", () => {
+ const docs = [
+ { title: "React Docs", startUrl: "https://react.dev/docs" },
+ { title: "React Tutorial", startUrl: "https://react.dev/tutorial" },
+ { title: "Vue Guide", startUrl: "https://vuejs.org/guide" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ // The component should group by domain when selected
+ // This test would need more sophisticated mocking to test the actual grouping
+ expect(screen.getAllByTestId("doc-item")).toHaveLength(3);
+ });
+
+ it("renders collapsible groups when grouping is enabled", () => {
+ const docs = [
+ { title: "GitHub Repo 1", startUrl: "https://github.com/user/repo1" },
+ { title: "GitHub Repo 2", startUrl: "https://github.com/user/repo2" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ // Check that docs are rendered
+ expect(screen.getAllByTestId("doc-item")).toHaveLength(2);
+ });
+
+ it("auto-expands groups when searching", async () => {
+ const docs = [
+ { title: "React Documentation", startUrl: "https://react.dev" },
+ { title: "Vue Guide", startUrl: "https://vuejs.org" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ const searchInput = screen.getByTestId("search-input");
+ fireEvent.change(searchInput, { target: { value: "React" } });
+
+ await waitFor(() => {
+ // Groups should be automatically expanded when searching
+ const docItems = screen.getAllByTestId("doc-item");
+ expect(docItems).toHaveLength(1);
+ });
+ });
+
+ it("categorizes docs correctly", () => {
+ const docs = [
+ { title: "GitHub Project", startUrl: "https://github.com/user/project" },
+ { title: "API Reference", startUrl: "https://example.com/api/reference" },
+ { title: "Tutorial", startUrl: "https://example.com/tutorial/intro" },
+ { title: "Blog Post", startUrl: "https://example.com/blog/post" },
+ { title: "Documentation", startUrl: "https://example.com/docs/guide" },
+ { title: "Other Resource", startUrl: "https://example.com/resource" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ // All docs should be rendered
+ expect(screen.getAllByTestId("doc-item")).toHaveLength(6);
+ });
+
+ it("handles empty docs list", () => {
+ const store = createMockStore([]);
+ renderWithProviders(, store);
+
+ expect(screen.getByText("Explore Blocks")).toBeInTheDocument();
+ expect(screen.queryByTestId("doc-item")).not.toBeInTheDocument();
+ });
+
+ it("debounces search input", async () => {
+ const docs = [
+ { title: "React Documentation", startUrl: "https://react.dev" },
+ { title: "Vue Guide", startUrl: "https://vuejs.org" },
+ ];
+ const store = createMockStore(docs);
+ renderWithProviders(, store);
+
+ const searchInput = screen.getByTestId("search-input");
+
+ // Type quickly
+ fireEvent.change(searchInput, { target: { value: "R" } });
+ fireEvent.change(searchInput, { target: { value: "Re" } });
+ fireEvent.change(searchInput, { target: { value: "Rea" } });
+ fireEvent.change(searchInput, { target: { value: "Reac" } });
+ fireEvent.change(searchInput, { target: { value: "React" } });
+
+ // Initially all docs should still be visible
+ expect(screen.getAllByTestId("doc-item")).toHaveLength(2);
+
+ // Wait for debounce
+ await waitFor(() => {
+ expect(screen.getAllByTestId("doc-item")).toHaveLength(1);
+ expect(screen.getByTestId("doc-item")).toHaveTextContent("React Documentation");
+ }, { timeout: 400 });
+ });
+});
diff --git a/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx
index e7913d1ef1..bb8ab68b05 100644
--- a/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx
+++ b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx
@@ -1,74 +1,302 @@
import { parseConfigYaml } from "@continuedev/config-yaml";
+import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { IndexingStatus } from "core";
-import { useMemo } from "react";
+import { useMemo, useState, useEffect } from "react";
import { useAuth } from "../../../../../context/Auth";
import { useAppSelector } from "../../../../../redux/hooks";
import { ExploreBlocksButton } from "../ExploreBlocksButton";
import DocsIndexingStatus from "./DocsIndexingStatus";
+import { Input } from "../../../../Input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../../../Select";
+
+type SortOption = "status" | "name" | "url";
+type GroupOption = "none" | "domain" | "category";
+
+interface CollapsibleGroupProps {
+ title: string;
+ count: number;
+ isExpanded: boolean;
+ onToggle: () = void;
+ children: React.ReactNode;
+}
+
+const CollapsibleGroup: React.FCCollapsibleGroupProps = ({
+ title,
+ count,
+ isExpanded,
+ onToggle,
+ children
+}) = {
+ return (
+ div className="mb-2"
+ button
+ onClick={onToggle}
+ className="flex items-center gap-1 w-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
+
+ {isExpanded ? (
+ ChevronDownIcon className="h-4 w-4" /
+ ) : (
+ ChevronRightIcon className="h-4 w-4" /
+ )}
+ span className="font-medium text-sm"{title}/span
+ span className="text-xs text-gray-500 ml-1"({count})/span
+ /button
+ {isExpanded (
+ div className="ml-4"
+ {children}
+ /div
+ )}
+ /div
+ );
+};
function DocsIndexingStatuses() {
- const config = useAppSelector((store) => store.config.config);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortBy, setSortBy] = useStateSortOption("status");
+ const [groupBy, setGroupBy] = useStateGroupOption("none");
+ const [expandedGroups, setExpandedGroups] = useStateSetstring(new Set());
+ const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
+
+ // Debounce search query
+ useEffect(() = {
+ const timer = setTimeout(() = {
+ setDebouncedSearchQuery(searchQuery);
+ }, 300);
+
+ return () = clearTimeout(timer);
+ }, [searchQuery]);
+
+ const config = useAppSelector((store) = store.config.config);
const indexingStatuses = useAppSelector(
- (store) => store.indexing.indexing.statuses,
+ (store) = store.indexing.indexing.statuses,
);
const { selectedProfile } = useAuth();
- const mergedDocs = useMemo(() => {
+ const mergedDocs = useMemo(() = {
const parsed = selectedProfile?.rawYaml
? parseConfigYaml(selectedProfile?.rawYaml ?? "")
: undefined;
- return (config.docs ?? []).map((doc, index) => ({
+ return (config.docs ?? []).map((doc, index) = ({
doc,
docFromYaml: parsed?.docs?.[index],
}));
}, [config.docs, selectedProfile?.rawYaml]);
- const sortedConfigDocs = useMemo(() => {
- const sorter = (status: IndexingStatus["status"]) => {
- if (status === "complete") return 0;
- if (status === "indexing" || status === "paused") return 1;
- if (status === "failed") return 2;
- if (status === "aborted" || status === "pending") return 3;
- return 4;
- };
-
- const docs = [...mergedDocs];
- docs.sort((a, b) => {
- const statusA = indexingStatuses[a.doc.startUrl]?.status ?? "pending";
- const statusB = indexingStatuses[b.doc.startUrl]?.status ?? "pending";
-
- // First, compare by status
- const statusCompare = sorter(statusA) - sorter(statusB);
- if (statusCompare !== 0) return statusCompare;
-
- // If status is the same, sort by presence of icon
- const hasIconA = !!a.doc.faviconUrl;
- const hasIconB = !!b.doc.faviconUrl;
- return hasIconB === hasIconA ? 0 : hasIconB ? 1 : -1;
+ // Filter docs based on search query
+ const filteredDocs = useMemo(() = {
+ const allDocs = (mergedDocs ?? []);
+ if (!debouncedSearchQuery) return allDocs;
+
+ const query = debouncedSearchQuery.toLowerCase();
+ return allDocs.filter(
+ ({ doc }) =
+ doc.title?.toLowerCase().includes(query) ||
+ doc.startUrl?.toLowerCase().includes(query)
+ );
+ }, [mergedDocs, debouncedSearchQuery]);
+
+ // Sort docs
+ const sortedConfigDocs = useMemo(() = {
+ const sorted = [...filteredDocs];
+ switch (sortBy) {
+ case "status":
+ const statusOrder = { indexing: 0, failed: 1, complete: 2, pending: 3, aborted: 4 };
+ sorted.sort((a, b) = {
+ const orderA = statusOrder[indexingStatuses[a.doc.startUrl]?.status ?? "pending"] ?? 5;
+ const orderB = statusOrder[indexingStatuses[b.doc.startUrl]?.status ?? "pending"] ?? 5;
+ return orderA - orderB;
+ });
+ break;
+ case "name":
+ sorted.sort((a, b) = (a.doc.title || "").localeCompare(b.doc.title || ""));
+ break;
+ case "url":
+ sorted.sort((a, b) = (a.doc.startUrl || "").localeCompare(b.doc.startUrl || ""));
+ break;
+ }
+ return sorted;
+ }, [filteredDocs, sortBy, indexingStatuses]);
+
+ // Helper function to categorize docs
+ const categorizeDoc = (doc: string): string = {
+ const url = doc.toLowerCase() || "";
+ if (url.includes("github.com")) return "GitHub";
+ if (url.includes("/docs") || url.includes("documentation")) return "Documentation";
+ if (url.includes("/api") || url.includes("reference")) return "API Reference";
+ if (url.includes("blog") || url.includes("article")) return "Blogs";
+ if (url.includes("tutorial") || url.includes("guide")) return "Tutorials Guides";
+ return "Other";
+ };
+
+ // Group docs
+ const groupedConfigDocs = useMemo(() = {
+ if (groupBy === "none") {
+ return { "All Documents": sortedConfigDocs };
+ }
+
+ const groups: Recordstring, { doc: any, docFromYaml: any }[] = {};
+
+ sortedConfigDocs.forEach((docConfig) = {
+ let groupKey: string;
+
+ if (groupBy === "domain") {
+ try {
+ const url = new URL(docConfig.doc.startUrl || "");
+ groupKey = url.hostname;
+ } catch {
+ groupKey = "Unknown Domain";
+ }
+ } else {
+ // groupBy === "category"
+ groupKey = categorizeDoc(docConfig.doc.startUrl);
+ }
+
+ if (!groups[groupKey]) {
+ groups[groupKey] = [];
+ }
+ groups[groupKey].push(docConfig);
+ });
+
+ // Sort groups by number of docs (descending)
+ const sortedGroups: Recordstring, { doc: any, docFromYaml: any }[] = {};
+ Object.entries(groups)
+ .sort(([, a], [, b]) = b.length - a.length)
+ .forEach(([key, value]) = {
+ sortedGroups[key] = value;
+ });
+
+ return sortedGroups;
+ }, [sortedConfigDocs, groupBy]);
+
+ const toggleGroup = (groupName: string) = {
+ setExpandedGroups((prev) = {
+ const newSet = new Set(prev);
+ if (newSet.has(groupName)) {
+ newSet.delete(groupName);
+ } else {
+ newSet.add(groupName);
+ }
+ return newSet;
});
- return docs;
- }, [mergedDocs, indexingStatuses]);
+ };
+
+ // Auto-expand groups when there's only one group or when searching
+ useEffect(() = {
+ const groupNames = Object.keys(groupedConfigDocs);
+ if (groupNames.length === 1 || debouncedSearchQuery) {
+ setExpandedGroups(new Set(groupNames));
+ }
+ }, [groupedConfigDocs, debouncedSearchQuery]);
+
+ const totalDocs = filteredDocs.length;
+ const isEmpty = totalDocs === 0 debouncedSearchQuery;
return (
-
-
- {sortedConfigDocs.map((docConfig) => {
- return (
-
- );
- })}
-
-
-
+ div className="space-y-4 overflow-y-auto"
+ {/* Search and Filter Controls */}
+ div className="sticky top-0 bg-white dark:bg-gray-900 z-10 pb-2 space-y-2"
+ Input
+ type="text"
+ placeholder="Search documentation..."
+ value={searchQuery}
+ onChange={(e) = setSearchQuery(e.target.value)}
+ className="w-full"
+ /
+
+ div className="flex gap-2"
+ Select value={sortBy} onValueChange={(value) = setSortBy(value as SortOption)}
+ SelectTrigger className="flex-1"
+ SelectValue placeholder="Sort by" /
+ /SelectTrigger
+ SelectContent
+ SelectItem value="status"Status/SelectItem
+ SelectItem value="name"Name/SelectItem
+ SelectItem value="url"URL/SelectItem
+ /SelectContent
+ /Select
+
+ Select value={groupBy} onValueChange={(value) = setGroupBy(value as GroupOption)}
+ SelectTrigger className="flex-1"
+ SelectValue placeholder="Group by" /
+ /SelectTrigger
+ SelectContent
+ SelectItem value="none"No Grouping/SelectItem
+ SelectItem value="domain"Domain/SelectItem
+ SelectItem value="category"Category/SelectItem
+ /SelectContent
+ /Select
+ /div
+
+ {searchQuery (
+ button
+ onClick={() = setSearchQuery("")}
+ className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
+
+ Clear search
+ /button
+ )}
+ /div
+
+ {/* Results */}
+ {isEmpty ? (
+ div className="text-center py-8 text-gray-500"
+ pNo documentation found matching "{debouncedSearchQuery}"/p
+ button
+ onClick={() = setSearchQuery("")}
+ className="mt-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
+
+ Clear search
+ /button
+ /div
+ ) : (
+ div
+ {Object.entries(groupedConfigDocs).map(([groupName, docs]) = {
+ const isExpanded = expandedGroups.has(groupName);
+ const showGroup = groupBy !== "none";
+
+ if (showGroup) {
+ return (
+ CollapsibleGroup
+ key={groupName}
+ title={groupName}
+ count={docs.length}
+ isExpanded={isExpanded}
+ onToggle={() = toggleGroup(groupName)}
+
+ {docs.map(({doc, docFromYaml}) = (
+ div key={doc.startUrl} className="flex items-center gap-2"
+ div className="flex-grow"
+ DocsIndexingStatus
+ docFromYaml={docFromYaml}
+ docConfig={doc}
+ /
+ /div
+ /div
+ ))}
+ /CollapsibleGroup
+ );
+ } else {
+ return docs.map(({doc, docFromYaml}) = (
+ div key={doc.startUrl} className="flex items-center gap-2"
+ div className="flex-grow"
+ DocsIndexingStatus
+ docFromYaml={docFromYaml}
+ docConfig={doc}
+ /
+ /div
+ /div
+ ));
+ }
+ })}
+ /div
+ )}
+ ExploreBlocksButton blockType="docs" /
+ /div
);
}
diff --git a/gui/src/utils/debounce.ts b/gui/src/utils/debounce.ts
new file mode 100644
index 0000000000..daca217a8c
--- /dev/null
+++ b/gui/src/utils/debounce.ts
@@ -0,0 +1,25 @@
+/**
+ * Creates a debounced function that delays invoking func until after wait milliseconds
+ * have elapsed since the last time the debounced function was invoked.
+ *
+ * @param func The function to debounce
+ * @param wait The number of milliseconds to delay
+ * @returns A debounced version of the function
+ */
+export function debounce any>(
+ func: T,
+ wait: number
+): (...args: Parameters) => void {
+ let timeoutId: NodeJS.Timeout | null = null;
+
+ return function debounced(...args: Parameters) {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ }
+
+ timeoutId = setTimeout(() => {
+ func(...args);
+ timeoutId = null;
+ }, wait);
+ };
+}