diff --git a/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx b/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx index 3788affd18..3cf612c926 100644 --- a/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx +++ b/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx @@ -11,7 +11,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { cn } from "../../ui/primitives/styles"; import { Tabs, TabsContent } from "../../ui/primitives/tabs"; import { useCrawlUrl, useUploadDocument } from "../hooks"; -import type { CrawlRequest, UploadMetadata } from "../types"; +import type { CrawlRequest, KnowledgeItemsFilter, UploadMetadata } from "../types"; import { KnowledgeTypeSelector } from "./KnowledgeTypeSelector"; import { LevelSelector } from "./LevelSelector"; import { TagInput } from "./TagInput"; @@ -21,6 +21,7 @@ interface AddKnowledgeDialogProps { onOpenChange: (open: boolean) => void; onSuccess: () => void; onCrawlStarted?: (progressId: string) => void; + currentFilter?: KnowledgeItemsFilter; } export const AddKnowledgeDialog: React.FC = ({ @@ -28,11 +29,12 @@ export const AddKnowledgeDialog: React.FC = ({ onOpenChange, onSuccess, onCrawlStarted, + currentFilter, }) => { const [activeTab, setActiveTab] = useState<"crawl" | "upload">("crawl"); const { showToast } = useToast(); - const crawlMutation = useCrawlUrl(); - const uploadMutation = useUploadDocument(); + const crawlMutation = useCrawlUrl(currentFilter); + const uploadMutation = useUploadDocument(currentFilter); // Generate unique IDs for form elements const urlId = useId(); @@ -83,7 +85,6 @@ export const AddKnowledgeDialog: React.FC = ({ showToast("Crawl started successfully", "success"); resetForm(); onSuccess(); - onOpenChange(false); } catch (error) { // Display the actual error message from backend const message = error instanceof Error ? error.message : "Failed to start crawl"; @@ -198,7 +199,7 @@ export const AddKnowledgeDialog: React.FC = ({
- +
({ }), })); +// Test filter for use in tests that require a current filter +const testCurrentFilter = { + knowledge_type: 'technical' as const, + search: '', + page: 1, + per_page: 100 +}; + // Test wrapper with QueryClient const createWrapper = () => { const queryClient = new QueryClient({ @@ -163,6 +171,10 @@ describe("useKnowledgeQueries", () => { }); describe("useCrawlUrl", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("should start crawl and return progress ID", async () => { const crawlRequest = { url: "https://example.com", @@ -203,9 +215,142 @@ describe("useKnowledgeQueries", () => { }), ).rejects.toThrow("Invalid URL"); }); + + it("should perform optimistic updates using provided current filter", async () => { + const crawlRequest = { + url: "https://example.com", + knowledge_type: "technical" as const, + tags: ["docs"], + max_depth: 2, + }; + + const mockResponse = { + success: true, + progressId: "progress-123", + message: "Crawling started", + estimatedDuration: "3-5 minutes", + }; + + const { knowledgeService } = await import("../../services"); + vi.mocked(knowledgeService.crawlUrl).mockResolvedValue(mockResponse); + + // Set up initial cache data + const initialData: KnowledgeItemsResponse = { + items: [], + total: 0, + page: 1, + per_page: 100, + }; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + // Set up cache with test filter + queryClient.setQueryData(knowledgeKeys.summaries(testCurrentFilter), initialData); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => useCrawlUrl(testCurrentFilter), { wrapper }); + + // Execute mutation + await result.current.mutateAsync(crawlRequest); + + // Verify optimistic update was applied to current filter cache + const updatedData = queryClient.getQueryData( + knowledgeKeys.summaries(testCurrentFilter) + ); + + expect(updatedData).toBeDefined(); + expect(updatedData?.items).toHaveLength(1); + expect(updatedData?.items[0]).toMatchObject({ + url: crawlRequest.url, + knowledge_type: crawlRequest.knowledge_type, + status: "processing", + }); + }); + + it("should update cache for matching filters during optimistic updates", async () => { + const crawlRequest = { + url: "https://example.com", + knowledge_type: "technical" as const, // Matches testCurrentFilter.knowledge_type + }; + + const mockResponse = { + success: true, + progressId: "progress-123", + message: "Crawling started", + }; + + const { knowledgeService } = await import("../../services"); + vi.mocked(knowledgeService.crawlUrl).mockResolvedValue(mockResponse); + + const initialData: KnowledgeItemsResponse = { + items: [], + total: 0, + page: 1, + per_page: 100, + }; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + // Pre-populate cache with current filter + queryClient.setQueryData(knowledgeKeys.summaries(testCurrentFilter), initialData); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => useCrawlUrl(testCurrentFilter), { wrapper }); + + await result.current.mutateAsync(crawlRequest); + + // Verify optimistic update was applied + const updatedData = queryClient.getQueryData( + knowledgeKeys.summaries(testCurrentFilter) + ); + + expect(updatedData?.items).toHaveLength(1); + expect(updatedData?.total).toBe(1); + }); + + it("should work without currentFilter parameter", async () => { + const crawlRequest = { + url: "https://example.com", + knowledge_type: "technical" as const, + }; + + const mockResponse = { + success: true, + progressId: "progress-123", + message: "Crawling started", + }; + + const { knowledgeService } = await import("../../services"); + vi.mocked(knowledgeService.crawlUrl).mockResolvedValue(mockResponse); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCrawlUrl(), { wrapper }); + + // Should work without currentFilter parameter + const response = await result.current.mutateAsync(crawlRequest); + expect(response).toEqual(mockResponse); + }); }); describe("useUploadDocument", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("should upload document with metadata", async () => { const file = new File(["test content"], "test.pdf", { type: "application/pdf" }); const metadata = { @@ -242,5 +387,169 @@ describe("useKnowledgeQueries", () => { await expect(result.current.mutateAsync({ file, metadata: {} })).rejects.toThrow("File too large"); }); + + it("should perform filter-aware optimistic updates for document uploads", async () => { + const file = new File(["test content"], "test.pdf", { type: "application/pdf" }); + const metadata = { + knowledge_type: "technical" as const, // Matches testCurrentFilter.knowledge_type + }; + + const mockResponse = { + success: true, + progressId: "upload-456", + message: "Upload started", + filename: "test.pdf", + }; + + const { knowledgeService } = await import("../../services"); + vi.mocked(knowledgeService.uploadDocument).mockResolvedValue(mockResponse); + + const initialData: KnowledgeItemsResponse = { + items: [], + total: 0, + page: 1, + per_page: 100, + }; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + queryClient.setQueryData(knowledgeKeys.summaries(testCurrentFilter), initialData); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => useUploadDocument(testCurrentFilter), { wrapper }); + + await result.current.mutateAsync({ file, metadata }); + + // Verify optimistic update was applied to the cache + const updatedData = queryClient.getQueryData( + knowledgeKeys.summaries(testCurrentFilter) + ); + + expect(updatedData?.items).toHaveLength(1); + expect(updatedData?.items[0]).toMatchObject({ + title: "test.pdf", + knowledge_type: metadata.knowledge_type, + status: "processing", + }); + }); + + it("should use provided current filter for optimistic updates", async () => { + const file = new File(["content"], "doc.pdf", { type: "application/pdf" }); + const metadata = { + knowledge_type: "technical" as const, // Matches test filter + }; + + const mockResponse = { + success: true, + progressId: "upload-789", + message: "Upload started", + filename: "doc.pdf", + }; + + const { knowledgeService } = await import("../../services"); + vi.mocked(knowledgeService.uploadDocument).mockResolvedValue(mockResponse); + + const initialData: KnowledgeItemsResponse = { + items: [], + total: 0, + page: 1, + per_page: 100, + }; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + queryClient.setQueryData(knowledgeKeys.summaries(testCurrentFilter), initialData); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => useUploadDocument(testCurrentFilter), { wrapper }); + + await result.current.mutateAsync({ file, metadata }); + + // Verify the cache was updated + const updatedData = queryClient.getQueryData( + knowledgeKeys.summaries(testCurrentFilter) + ); + + expect(updatedData?.items).toHaveLength(1); + }); + }); + + describe("Filter Parameter Integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should prioritize provided current filter updates over other cache keys", async () => { + // This test verifies the core enhancement: prioritizing provided current filter updates + const crawlRequest = { + url: "https://example.com", + knowledge_type: "technical" as const, // Matches testCurrentFilter.knowledge_type + }; + + const mockResponse = { + success: true, + progressId: "priority-test", + message: "Crawling started", + }; + + // Set up multiple cached filters + const otherFilter = { knowledge_type: 'business' as const, search: '', page: 1, per_page: 100 }; + + const { knowledgeService } = await import("../../services"); + vi.mocked(knowledgeService.crawlUrl).mockResolvedValue(mockResponse); + + const initialData: KnowledgeItemsResponse = { + items: [], + total: 0, + page: 1, + per_page: 100, + }; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + // Set up both caches + queryClient.setQueryData(knowledgeKeys.summaries(testCurrentFilter), initialData); + queryClient.setQueryData(knowledgeKeys.summaries(otherFilter), initialData); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => useCrawlUrl(testCurrentFilter), { wrapper }); + + await result.current.mutateAsync(crawlRequest); + + // Verify current filter cache was updated first (gets priority) + const currentFilterData = queryClient.getQueryData( + knowledgeKeys.summaries(testCurrentFilter) + ); + const otherFilterData = queryClient.getQueryData( + knowledgeKeys.summaries(otherFilter) + ); + + // Current filter should be updated since knowledge_type matches + expect(currentFilterData?.items).toHaveLength(1); + + // Other filter should remain unchanged (no knowledge_type match) + expect(otherFilterData?.items).toHaveLength(0); + }); }); }); diff --git a/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts b/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts index bf2ab907e8..a44dd3bf0e 100644 --- a/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts +++ b/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts @@ -7,7 +7,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { useSmartPolling } from "@/features/shared/hooks"; import { useToast } from "@/features/shared/hooks/useToast"; -import { createOptimisticEntity, createOptimisticId } from "@/features/shared/utils/optimistic"; +import { createOptimisticEntity, createOptimisticId, replaceOptimisticEntity, removeDuplicateEntities } from "@/features/shared/utils/optimistic"; import { useActiveOperations } from "../../progress/hooks"; import { progressKeys } from "../../progress/hooks/useProgressQueries"; import type { ActiveOperation, ActiveOperationsResponse } from "../../progress/types"; @@ -23,6 +23,56 @@ import type { } from "../types"; import { getProviderErrorMessage } from "../utils/providerErrorHandler"; +/** + * Helper function to check if a knowledge item matches the given filter + */ +function itemMatchesFilter(item: KnowledgeItem & Partial<{ metadata: { tags?: string[] } }>, filter?: KnowledgeItemsFilter) { + if (!filter) return true; + const tags = item.metadata?.tags ?? []; + + // Check basic filter criteria + const basicMatch = ( + (!filter.knowledge_type || item.knowledge_type === filter.knowledge_type) && + (!filter.source_type || item.source_type === filter.source_type) && + (!filter.tags || filter.tags.every((t) => tags.some(tag => tag.toLowerCase().includes(t.toLowerCase())))) + ); + + // Enhanced search across title, URL, description, and metadata + if (filter.search && basicMatch) { + const searchTerm = filter.search.toLowerCase(); + const title = item.title?.toLowerCase() || ''; + const url = item.url?.toLowerCase() || ''; + const description = item.metadata?.description?.toLowerCase() || ''; + + return title.includes(searchTerm) || + url.includes(searchTerm) || + description.includes(searchTerm); + } + + return basicMatch; +} + +/** + * Helper function to check filter equality for cache skip logic + */ +function filtersEqual(filter1?: KnowledgeItemsFilter, filter2?: KnowledgeItemsFilter): boolean { + if (!filter1 && !filter2) return true; + if (!filter1 || !filter2) return false; + + // Compare tags arrays + const tagsEqual = (filter1.tags?.length || 0) === (filter2.tags?.length || 0) && + (filter1.tags || []).every((tag, index) => tag === (filter2.tags || [])[index]); + + return ( + filter1.page === filter2.page && + filter1.per_page === filter2.per_page && + filter1.search === filter2.search && + filter1.knowledge_type === filter2.knowledge_type && + filter1.source_type === filter2.source_type && + tagsEqual + ); +} + // Query keys factory for better organization and type safety export const knowledgeKeys = { all: ["knowledge"] as const, @@ -93,11 +143,12 @@ export function useCodeExamples(sourceId: string | null) { }); } + /** * Crawl URL mutation with optimistic updates * Returns the progressId that can be used to track crawl progress */ -export function useCrawlUrl() { +export function useCrawlUrl(currentFilter?: KnowledgeItemsFilter) { const queryClient = useQueryClient(); const { showToast } = useToast(); @@ -106,11 +157,10 @@ export function useCrawlUrl() { Error, CrawlRequest, { - previousKnowledge?: KnowledgeItem[]; previousSummaries?: Array<[readonly unknown[], KnowledgeItemsResponse | undefined]>; previousOperations?: ActiveOperationsResponse; tempProgressId: string; - tempItemId: string; + optimisticId: string; } >({ mutationFn: (request: CrawlRequest) => knowledgeService.crawlUrl(request), @@ -119,23 +169,8 @@ export function useCrawlUrl() { await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() }); await queryClient.cancelQueries({ queryKey: progressKeys.active() }); - // TODO: Fix invisible optimistic updates - // ISSUE: Optimistic updates are applied to knowledgeKeys.summaries(filter) queries, - // but the UI component (KnowledgeView) queries with dynamic filters that we don't have access to here. - // This means optimistic updates only work if the filter happens to match what's being viewed. - // - // CURRENT BEHAVIOR: - // - We update all cached summaries queries (lines 158-179 below) - // - BUT if the user changes filters after mutation starts, they won't see the optimistic update - // - AND we have no way to know what filter the user is currently viewing - // - // PROPER FIX requires one of: - // 1. Pass current filter from KnowledgeView to mutation hooks (prop drilling) - // 2. Create KnowledgeFilterContext to share filter state - // 3. Restructure to have a single source of truth query key like other features - // - // IMPACT: Users don't see immediate feedback when adding knowledge items - items only - // appear after the server responds (usually 1-3 seconds later) + // Optimistic updates target the currently viewed filter by + // checking if the new item matches the filter passed to the hook. // Snapshot the previous values for rollback const previousSummaries = queryClient.getQueriesData({ @@ -171,22 +206,56 @@ export function useCrawlUrl() { updated_at: new Date().toISOString(), } as Omit); - // Update all summaries caches with optimistic data, respecting each cache's filter + // Prioritize updating the currently viewed filter for immediate user feedback + if (currentFilter) { + const currentQueryKey = knowledgeKeys.summaries(currentFilter); + const currentData = queryClient.getQueryData(currentQueryKey); + + // Check if the optimistic item matches the current filter + if (itemMatchesFilter(optimisticItem, currentFilter)) { + if (!currentData) { + queryClient.setQueryData(currentQueryKey, { + items: [optimisticItem], + total: 1, + page: 1, + per_page: currentFilter?.per_page ?? 100, + }); + } else { + queryClient.setQueryData(currentQueryKey, { + ...currentData, + items: [optimisticItem, ...currentData.items], + total: (currentData.total ?? currentData.items.length) + 1, + }); + } + } + } + + // Also update all other cached summaries for completeness const entries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summariesPrefix(), }); for (const [qk, old] of entries) { + // Skip if this is the current query we already updated + const currentQueryKey = currentFilter ? knowledgeKeys.summaries(currentFilter) : null; + if (currentQueryKey && qk.length === currentQueryKey.length && + qk.every((part, index) => { + if (index < qk.length - 1) return part === currentQueryKey[index]; + // For the filter object, use the helper for deep comparison + const qkFilter = part as KnowledgeItemsFilter | undefined; + const currentFilterPart = currentQueryKey[index] as KnowledgeItemsFilter | undefined; + return filtersEqual(qkFilter, currentFilterPart); + })) { + continue; + } + const filter = qk[qk.length - 1] as KnowledgeItemsFilter | undefined; - const matchesType = !filter?.knowledge_type || optimisticItem.knowledge_type === filter.knowledge_type; - const matchesTags = - !filter?.tags || filter.tags.every((t) => (optimisticItem.metadata?.tags ?? []).includes(t)); - if (!(matchesType && matchesTags)) continue; + if (!itemMatchesFilter(optimisticItem, filter)) continue; if (!old) { queryClient.setQueryData(qk, { items: [optimisticItem], total: 1, page: 1, - per_page: 100, + per_page: filter?.per_page ?? 100, }); } else { queryClient.setQueryData(qk, { @@ -228,25 +297,34 @@ export function useCrawlUrl() { }); // Return context for rollback and replacement - return { previousSummaries, previousOperations, tempProgressId }; + return { previousSummaries, previousOperations, tempProgressId, optimisticId: optimisticItem._localId }; }, onSuccess: (response, _variables, context) => { - // Replace temporary IDs with real ones from the server + // Replace temporary IDs with real ones from the server using shared utilities if (context) { - // Update summaries cache with real progress ID + // Update summaries cache using replaceOptimisticEntity and removeDuplicateEntities queryClient.setQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => { if (!old) return old; + + // Find the optimistic item to create server entity with updated data + const optimisticItem = old.items.find(item => item._localId === context.optimisticId); + if (!optimisticItem) return old; + + const serverItem: KnowledgeItem = { + ...optimisticItem, + source_id: response.progressId, + _optimistic: false, + _localId: undefined, + } as KnowledgeItem; + + // Replace the optimistic entity with server data + const replacedItems = replaceOptimisticEntity(old.items, context.optimisticId, serverItem); + // Remove any duplicates that might have been created + const deduplicatedItems = removeDuplicateEntities(replacedItems); + return { ...old, - items: old.items.map((item) => { - if (item.source_id === context.tempProgressId) { - return { - ...item, - source_id: response.progressId, - }; - } - return item; - }), + items: deduplicatedItems, }; }); @@ -300,7 +378,7 @@ export function useCrawlUrl() { /** * Upload document mutation with optimistic updates */ -export function useUploadDocument() { +export function useUploadDocument(currentFilter?: KnowledgeItemsFilter) { const queryClient = useQueryClient(); const { showToast } = useToast(); @@ -312,6 +390,7 @@ export function useUploadDocument() { previousSummaries?: Array<[readonly unknown[], KnowledgeItemsResponse | undefined]>; previousOperations?: ActiveOperationsResponse; tempProgressId: string; + optimisticId: string; } >({ mutationFn: ({ file, metadata }: { file: File; metadata: UploadMetadata }) => @@ -351,22 +430,56 @@ export function useUploadDocument() { updated_at: new Date().toISOString(), } as Omit); - // Respect each cache's filter (knowledge_type, tags, etc.) + // Prioritize updating the currently viewed filter for immediate user feedback + if (currentFilter) { + const currentQueryKey = knowledgeKeys.summaries(currentFilter); + const currentData = queryClient.getQueryData(currentQueryKey); + + // Check if the optimistic item matches the current filter + if (itemMatchesFilter(optimisticItem, currentFilter)) { + if (!currentData) { + queryClient.setQueryData(currentQueryKey, { + items: [optimisticItem], + total: 1, + page: 1, + per_page: currentFilter?.per_page ?? 100, + }); + } else { + queryClient.setQueryData(currentQueryKey, { + ...currentData, + items: [optimisticItem, ...currentData.items], + total: (currentData.total ?? currentData.items.length) + 1, + }); + } + } + } + + // Also update all other cached summaries for completeness const entries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summariesPrefix(), }); for (const [qk, old] of entries) { + // Skip if this is the current query we already updated + const currentQueryKey = currentFilter ? knowledgeKeys.summaries(currentFilter) : null; + if (currentQueryKey && qk.length === currentQueryKey.length && + qk.every((part, index) => { + if (index < qk.length - 1) return part === currentQueryKey[index]; + // For the filter object, use the helper for deep comparison + const qkFilter = part as KnowledgeItemsFilter | undefined; + const currentFilterPart = currentQueryKey[index] as KnowledgeItemsFilter | undefined; + return filtersEqual(qkFilter, currentFilterPart); + })) { + continue; + } + const filter = qk[qk.length - 1] as KnowledgeItemsFilter | undefined; - const matchesType = !filter?.knowledge_type || optimisticItem.knowledge_type === filter.knowledge_type; - const matchesTags = - !filter?.tags || filter.tags.every((t) => (optimisticItem.metadata?.tags ?? []).includes(t)); - if (!(matchesType && matchesTags)) continue; + if (!itemMatchesFilter(optimisticItem, filter)) continue; if (!old) { queryClient.setQueryData(qk, { items: [optimisticItem], total: 1, page: 1, - per_page: 100, + per_page: filter?.per_page ?? 100, }); } else { queryClient.setQueryData(qk, { @@ -407,25 +520,34 @@ export function useUploadDocument() { }; }); - return { previousSummaries, previousOperations, tempProgressId }; + return { previousSummaries, previousOperations, tempProgressId, optimisticId: optimisticItem._localId }; }, onSuccess: (response, _variables, context) => { - // Replace temporary IDs with real ones from the server + // Replace temporary IDs with real ones from the server using shared utilities if (context && response?.progressId) { - // Update summaries cache with real progress ID + // Update summaries cache using shared utilities queryClient.setQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => { if (!old) return old; + + // Find the optimistic item to create server entity with updated data + const optimisticItem = old.items.find(item => item._localId === context.optimisticId); + if (!optimisticItem) return old; + + const serverItem: KnowledgeItem = { + ...optimisticItem, + source_id: response.progressId, + _optimistic: false, + _localId: undefined, + } as KnowledgeItem; + + // Replace the optimistic entity with server data + const replacedItems = replaceOptimisticEntity(old.items, context.optimisticId, serverItem); + // Remove any duplicates that might have been created + const deduplicatedItems = removeDuplicateEntities(replacedItems); + return { ...old, - items: old.items.map((item) => { - if (item.source_id === context.tempProgressId) { - return { - ...item, - source_id: response.progressId, - }; - } - return item; - }), + items: deduplicatedItems, }; }); diff --git a/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx b/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx index 0bedc7b29f..fb09bd1a06 100644 --- a/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx +++ b/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx @@ -14,37 +14,42 @@ import { useKnowledgeSummaries } from "../hooks/useKnowledgeQueries"; import { KnowledgeInspector } from "../inspector/components/KnowledgeInspector"; import type { KnowledgeItem, KnowledgeItemsFilter } from "../types"; +const PAGE_SIZE = 100; +const SEARCH_DEBOUNCE_MS = 300; + export const KnowledgeView = () => { + // Local filter state (following Tasks/Projects pattern) + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + const [typeFilter, setTypeFilter] = useState<"technical" | "business" | undefined>(undefined); + + // Debounce search query to avoid excessive API calls + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, SEARCH_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [searchQuery]); + + // Compute current filter from local state + const currentFilter: KnowledgeItemsFilter = useMemo(() => ({ + page: 1, + per_page: PAGE_SIZE, + ...(debouncedSearchQuery && { search: debouncedSearchQuery }), + ...(typeFilter && { knowledge_type: typeFilter }), + }), [debouncedSearchQuery, typeFilter]); + // View state const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); - const [searchQuery, setSearchQuery] = useState(""); - const [typeFilter, setTypeFilter] = useState<"all" | "technical" | "business">("all"); // Dialog state const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [inspectorItem, setInspectorItem] = useState(null); const [inspectorInitialTab, setInspectorInitialTab] = useState<"documents" | "code">("documents"); - // Build filter object for API - memoize to prevent recreating on every render - const filter = useMemo(() => { - const f: KnowledgeItemsFilter = { - page: 1, - per_page: 100, - }; - - if (searchQuery) { - f.search = searchQuery; - } - - if (typeFilter !== "all") { - f.knowledge_type = typeFilter; - } - - return f; - }, [searchQuery, typeFilter]); - - // Fetch knowledge summaries (no automatic polling!) - const { data, isLoading, error, refetch, setActiveCrawlIds, activeOperations } = useKnowledgeSummaries(filter); + // Fetch knowledge summaries using current filter + const { data, isLoading, error, refetch, setActiveCrawlIds, activeOperations } = useKnowledgeSummaries(currentFilter); const knowledgeItems = data?.items || []; const totalItems = data?.total || 0; @@ -75,10 +80,11 @@ export const KnowledgeView = () => { // Show error message with details const errorMessage = op.message || op.error || "Operation failed"; showToast(`❌ ${errorMessage}`, "error", 7000); - } else if (op.status === "completed") { - // Show success message - const message = op.message || "Operation completed"; - showToast(`✅ ${message}`, "success", 5000); + } else { + // Show success message for any completed operation (not just "completed" status) + const operationType = op.operation_type || "Operation"; + const successMessage = op.message || `${operationType} completed successfully`; + showToast(`✅ ${successMessage}`, "success", 5000); } // Remove from active crawl IDs @@ -171,6 +177,7 @@ export const KnowledgeView = () => { { setIsAddDialogOpen(false); refetch();