From f4ad78543915ad6af2ff567c2c064db4d67667a0 Mon Sep 17 00:00:00 2001 From: Wirasm <152263317+Wirasm@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:05:03 +0300 Subject: [PATCH] refactor: Phase 2 Query Keys Standardization - Complete TanStack Query v5 patterns implementation (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: complete Phase 2 Query Keys Standardization Standardize query keys across all features following vertical slice architecture, ensuring they mirror backend API structure exactly with no backward compatibility. Key Changes: - Refactor all query key factories to follow consistent patterns - Move progress feature from knowledge/progress to top-level /features/progress - Create shared query patterns for consistency (DISABLED_QUERY_KEY, STALE_TIMES) - Remove all hardcoded stale times and disabled keys - Update all imports after progress feature relocation Query Key Factories Standardized: - projectKeys: removed task-related keys (tasks, taskCounts) - taskKeys: added dual nature support (global via lists(), project-scoped via byProject()) - knowledgeKeys: removed redundant methods (details, summary) - progressKeys: new top-level feature with consistent factory - documentKeys: full factory pattern with versions support - mcpKeys: complete with health endpoint Shared Patterns Implementation: - STALE_TIMES: instant (0), realtime (3s), frequent (5s), normal (30s), rare (5m), static (∞) - DISABLED_QUERY_KEY: consistent disabled query pattern across all features - Removed unused createQueryOptions helper Testing: - Added comprehensive tests for progress hooks - Updated all test mocks to include new STALE_TIMES values - All 81 feature tests passing Documentation: - Created QUERY_PATTERNS.md guide for future implementations - Clear patterns, examples, and migration checklist Breaking Changes: - Progress imports moved from knowledge/progress to progress - Query key structure changes (cache will reset) - No backward compatibility maintained Co-Authored-By: Claude * fix: establish single source of truth for tags in metadata - Remove ambiguous top-level tags field from KnowledgeItem interface - Update all UI components to use metadata.tags exclusively - Fix mutations to correctly update tags in metadata object - Remove duplicate tags field from backend KnowledgeSummaryService - Fix test setup issue with QueryClient instance in knowledge tests - Add TODO comments for filter-blind optimistic updates (Phase 3) This eliminates the ambiguity identified in Phase 2 where both item.tags and metadata.tags existed, establishing metadata.tags as the single source of truth across the entire stack. * fix: comprehensive progress hooks improvements - Integrate useSmartPolling for all polling queries - Fix memory leaks from uncleaned timeouts - Replace string-based error checking with status codes - Remove TypeScript any usage with proper types - Fix unstable dependencies with sorted JSON serialization - Add staleTime to document queries for consistency * feat: implement flexible assignee system for dynamic agents - Changed assignee from restricted enum to flexible string type - Renamed "AI IDE Agent" to "Coding Agent" for clarity - Enhanced ComboBox with Radix UI best practices: - Full ARIA compliance (roles, labels, keyboard nav) - Performance optimizations (memoization, useCallback) - Improved UX (auto-scroll, keyboard shortcuts) - Fixed event bubbling preventing unintended modal opens - Updated MCP server docs to reflect flexible assignee capability - Removed unnecessary UI elements (arrows, helper text) - Styled ComboBox to match priority selector aesthetic This allows external MCP clients to create and assign custom sub-agents dynamically, supporting advanced agent orchestration workflows. 🤖 Generated with Claude Code Co-Authored-By: Claude * fix: complete Phase 2 summariesPrefix usage for cache consistency - Fix all knowledgeKeys.summaries() calls to use summariesPrefix() for operations targeting multiple summary caches - Update cancelQueries, getQueriesData, setQueriesData, invalidateQueries, and refetchQueries calls - Fix critical cache invalidation bug where filtered summaries weren't being cleared - Update test expectations to match new factory patterns - Address CodeRabbit review feedback on cache stability issues This completes the Phase 2 Query Keys Standardization work documented in PRPs/local/frontend-state-management-refactor.md 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: update MCP task tools documentation for Coding Agent rename Update task assignee documentation from "AI IDE Agent" to "Coding Agent" to match frontend changes for consistency across the system. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: implement assignee filtering in MCP find_tasks function Add missing implementation for filter_by="assignee" that was documented but not coded. The filter now properly passes the assignee parameter to the backend API, matching the existing pattern used for status filtering. Fixes documentation/implementation mismatch identified by CodeRabbit. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: Phase 2 cleanup - address review comments and improve code quality Changes made: - Reduced smart polling interval from 60s to 5s for background tabs (better responsiveness) - Fixed cache coherence bug in knowledge queries (missing limit parameter) - Standardized "Coding Agent" naming (was inconsistently "AI IDE Agent") - Improved task queries with 2s polling, type safety, and proper invalidation - Enhanced combobox accessibility with proper ARIA attributes and IDs - Delegated useCrawlProgressPolling to useActiveOperations (removed duplication) - Added exact: true to progress query removals (prevents sibling removal) - Fixed invalid Tailwind class ml-4.5 to ml-4 All changes align with Phase 2 query key standardization goals and improve overall code quality, accessibility, and performance. Co-Authored-By: Claude --------- Co-authored-by: Claude --- PRPs/ai_docs/API_NAMING_CONVENTIONS.md | 3 +- PRPs/ai_docs/QUERY_PATTERNS.md | 227 +++++++++++ .../components/AddKnowledgeDialog.tsx | 2 +- .../knowledge/components/KnowledgeCard.tsx | 8 +- .../knowledge/components/KnowledgeList.tsx | 2 +- .../hooks/tests/useKnowledgeQueries.test.ts | 36 +- .../knowledge/hooks/useKnowledgeQueries.ts | 213 +++++++---- .../src/features/knowledge/index.ts | 3 - .../knowledge/services/knowledgeService.ts | 5 +- .../src/features/knowledge/types/index.ts | 1 - .../knowledge/views/KnowledgeView.tsx | 4 +- .../src/features/mcp/hooks/useMcpQueries.ts | 10 +- .../progress/components/CrawlingProgress.tsx | 6 +- .../components/KnowledgeCardProgress.tsx | 2 +- .../progress/components/index.ts | 0 .../{knowledge => }/progress/hooks/index.ts | 0 .../hooks/tests/useProgressQueries.test.ts | 260 +++++++++++++ .../progress/hooks/useProgressQueries.ts | 186 +++++---- .../{knowledge => }/progress/index.ts | 0 .../progress/services/index.ts | 0 .../progress/services/progressService.ts | 2 +- .../{knowledge => }/progress/types/index.ts | 0 .../progress/types/progress.ts | 0 .../documents/hooks/useDocumentQueries.ts | 15 +- .../src/features/projects/hooks/index.ts | 1 - .../hooks/tests/useProjectQueries.test.ts | 4 +- .../projects/hooks/useProjectQueries.ts | 32 +- .../tasks/components/EditableTableCell.tsx | 54 ++- .../tasks/components/TaskAssignee.tsx | 192 +++++----- .../tasks/components/TaskEditModal.tsx | 37 +- .../features/projects/tasks/hooks/index.ts | 1 + .../tasks/hooks/tests/useTaskQueries.test.ts | 9 +- .../projects/tasks/hooks/useTaskQueries.ts | 75 ++-- .../features/projects/tasks/schemas/index.ts | 7 +- .../tasks/services/tests/taskService.test.ts | 2 +- .../features/projects/tasks/types/index.ts | 4 + .../src/features/projects/tasks/types/task.ts | 14 +- .../projects/tasks/utils/task-styles.tsx | 4 +- .../features/projects/views/ProjectsView.tsx | 9 +- .../src/features/shared/queryPatterns.ts | 24 ++ .../src/features/ui/hooks/useSmartPolling.ts | 4 +- .../src/features/ui/primitives/combobox.tsx | 356 ++++++++++++------ .../mcp_server/features/tasks/task_tools.py | 19 +- .../knowledge/knowledge_summary_service.py | 3 +- 44 files changed, 1344 insertions(+), 492 deletions(-) create mode 100644 PRPs/ai_docs/QUERY_PATTERNS.md rename archon-ui-main/src/features/{knowledge => }/progress/components/CrawlingProgress.tsx (98%) rename archon-ui-main/src/features/{knowledge => }/progress/components/KnowledgeCardProgress.tsx (98%) rename archon-ui-main/src/features/{knowledge => }/progress/components/index.ts (100%) rename archon-ui-main/src/features/{knowledge => }/progress/hooks/index.ts (100%) create mode 100644 archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts rename archon-ui-main/src/features/{knowledge => }/progress/hooks/useProgressQueries.ts (63%) rename archon-ui-main/src/features/{knowledge => }/progress/index.ts (100%) rename archon-ui-main/src/features/{knowledge => }/progress/services/index.ts (100%) rename archon-ui-main/src/features/{knowledge => }/progress/services/progressService.ts (91%) rename archon-ui-main/src/features/{knowledge => }/progress/types/index.ts (100%) rename archon-ui-main/src/features/{knowledge => }/progress/types/progress.ts (100%) create mode 100644 archon-ui-main/src/features/shared/queryPatterns.ts diff --git a/PRPs/ai_docs/API_NAMING_CONVENTIONS.md b/PRPs/ai_docs/API_NAMING_CONVENTIONS.md index 82a97dfb..ef22a67b 100644 --- a/PRPs/ai_docs/API_NAMING_CONVENTIONS.md +++ b/PRPs/ai_docs/API_NAMING_CONVENTIONS.md @@ -109,7 +109,8 @@ GET /api/agent-chat/sessions/{id}/messages - Chat messages ### Database Types (from backend) ```typescript type DatabaseTaskStatus = 'todo' | 'doing' | 'review' | 'done'; -type Assignee = 'User' | 'Archon' | 'AI IDE Agent'; +type Assignee = string; // Flexible string to support any agent name +// Common values: 'User', 'Archon', 'Coding Agent' ``` ### Request/Response Types diff --git a/PRPs/ai_docs/QUERY_PATTERNS.md b/PRPs/ai_docs/QUERY_PATTERNS.md new file mode 100644 index 00000000..e4b56988 --- /dev/null +++ b/PRPs/ai_docs/QUERY_PATTERNS.md @@ -0,0 +1,227 @@ +# TanStack Query Patterns Guide + +This guide documents the standardized patterns for using TanStack Query v5 in the Archon frontend. + +## Core Principles + +1. **Feature Ownership**: Each feature owns its query keys in `{feature}/hooks/use{Feature}Queries.ts` +2. **Consistent Patterns**: Always use shared patterns from `shared/queryPatterns.ts` +3. **No Hardcoded Values**: Never hardcode stale times or disabled keys +4. **Mirror Backend API**: Query keys should exactly match backend API structure + +## Query Key Factory Pattern + +Every feature MUST implement a query key factory following this pattern: + +```typescript +// features/{feature}/hooks/use{Feature}Queries.ts +export const featureKeys = { + all: ["feature"] as const, // Base key for the domain + lists: () => [...featureKeys.all, "list"] as const, // For list endpoints + detail: (id: string) => [...featureKeys.all, "detail", id] as const, // For single item + // Add more as needed following backend routes +}; +``` + +### Examples from Codebase + +```typescript +// Projects - Simple hierarchy +export const projectKeys = { + all: ["projects"] as const, + lists: () => [...projectKeys.all, "list"] as const, + detail: (id: string) => [...projectKeys.all, "detail", id] as const, + features: (id: string) => [...projectKeys.all, id, "features"] as const, +}; + +// Tasks - Dual nature (global and project-scoped) +export const taskKeys = { + all: ["tasks"] as const, + lists: () => [...taskKeys.all, "list"] as const, // /api/tasks + detail: (id: string) => [...taskKeys.all, "detail", id] as const, + byProject: (projectId: string) => ["projects", projectId, "tasks"] as const, // /api/projects/{id}/tasks + counts: () => [...taskKeys.all, "counts"] as const, +}; +``` + +## Shared Patterns Usage + +### Import Required Patterns + +```typescript +import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/queryPatterns"; +``` + +### Disabled Queries + +Always use `DISABLED_QUERY_KEY` when a query should not execute: + +```typescript +// ✅ CORRECT +queryKey: projectId ? projectKeys.detail(projectId) : DISABLED_QUERY_KEY, + +// ❌ WRONG - Don't create custom disabled keys +queryKey: projectId ? projectKeys.detail(projectId) : ["projects-undefined"], +``` + +### Stale Times + +Always use `STALE_TIMES` constants for cache configuration: + +```typescript +// ✅ CORRECT +staleTime: STALE_TIMES.normal, // 30 seconds +staleTime: STALE_TIMES.frequent, // 5 seconds +staleTime: STALE_TIMES.instant, // 0 - always fresh + +// ❌ WRONG - Don't hardcode times +staleTime: 30000, +staleTime: 0, +``` + +#### STALE_TIMES Reference + +- `instant: 0` - Always fresh (real-time data like active progress) +- `realtime: 3_000` - 3 seconds (near real-time updates) +- `frequent: 5_000` - 5 seconds (frequently changing data) +- `normal: 30_000` - 30 seconds (standard cache time) +- `rare: 300_000` - 5 minutes (rarely changing config) +- `static: Infinity` - Never stale (settings, auth) + +## Complete Hook Pattern + +```typescript +export function useFeatureDetail(id: string | undefined) { + return useQuery({ + queryKey: id ? featureKeys.detail(id) : DISABLED_QUERY_KEY, + queryFn: () => id + ? featureService.getFeatureById(id) + : Promise.reject("No ID provided"), + enabled: !!id, + staleTime: STALE_TIMES.normal, + }); +} +``` + +## Mutations with Optimistic Updates + +```typescript +export function useCreateFeature() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateFeatureRequest) => featureService.create(data), + + onMutate: async (newData) => { + // Cancel in-flight queries + await queryClient.cancelQueries({ queryKey: featureKeys.lists() }); + + // Snapshot for rollback + const previous = queryClient.getQueryData(featureKeys.lists()); + + // Optimistic update (use timestamp IDs for now - Phase 3 will use UUIDs) + const tempId = `temp-${Date.now()}`; + queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) => + [...old, { ...newData, id: tempId }] + ); + + return { previous, tempId }; + }, + + onError: (err, variables, context) => { + // Rollback on error + if (context?.previous) { + queryClient.setQueryData(featureKeys.lists(), context.previous); + } + }, + + onSuccess: (data, variables, context) => { + // Replace optimistic with real data + queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) => + old.map(item => item.id === context?.tempId ? data : item) + ); + }, + }); +} +``` + +## Testing Query Hooks + +Always mock both services and shared patterns: + +```typescript +// Mock services +vi.mock("../../services", () => ({ + featureService: { + getList: vi.fn(), + getById: vi.fn(), + }, +})); + +// Mock shared patterns with ALL values +vi.mock("../../../shared/queryPatterns", () => ({ + DISABLED_QUERY_KEY: ["disabled"] as const, + STALE_TIMES: { + instant: 0, + realtime: 3_000, + frequent: 5_000, + normal: 30_000, + rare: 300_000, + static: Infinity, + }, +})); +``` + +## Vertical Slice Architecture + +Each feature is self-contained: + +``` +src/features/projects/ +├── components/ # UI components +├── hooks/ +│ └── useProjectQueries.ts # Query hooks & keys +├── services/ +│ └── projectService.ts # API calls +└── types/ + └── index.ts # TypeScript types +``` + +Sub-features (like tasks under projects) follow the same structure: + +``` +src/features/projects/tasks/ +├── components/ +├── hooks/ +│ └── useTaskQueries.ts # Own query keys! +├── services/ +└── types/ +``` + +## Migration Checklist + +When refactoring to these patterns: + +- [ ] Create query key factory in `hooks/use{Feature}Queries.ts` +- [ ] Import `DISABLED_QUERY_KEY` and `STALE_TIMES` from shared +- [ ] Replace all hardcoded disabled keys with `DISABLED_QUERY_KEY` +- [ ] Replace all hardcoded stale times with `STALE_TIMES` constants +- [ ] Update all `queryKey` references to use factory +- [ ] Update all `invalidateQueries` to use factory +- [ ] Update all `setQueryData` to use factory +- [ ] Add comprehensive tests for query keys +- [ ] Remove any backward compatibility code + +## Common Pitfalls to Avoid + +1. **Don't create centralized query keys** - Each feature owns its keys +2. **Don't hardcode values** - Use shared constants +3. **Don't mix concerns** - Tasks shouldn't import projectKeys +4. **Don't skip mocking in tests** - Mock both services and patterns +5. **Don't use inconsistent patterns** - Follow the established conventions + +## Future Improvements (Phase 3+) + +- Replace timestamp IDs (`temp-${Date.now()}`) with UUIDs +- Add Server-Sent Events for real-time updates +- Consider Zustand for complex client state \ No newline at end of file diff --git a/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx b/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx index 10d30d06..f69db10e 100644 --- a/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx +++ b/archon-ui-main/src/features/knowledge/components/AddKnowledgeDialog.tsx @@ -9,7 +9,7 @@ import { useToast } from "../../ui/hooks/useToast"; import { Button, Input, Label } from "../../ui/primitives"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../ui/primitives/dialog"; import { cn } from "../../ui/primitives/styles"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives/tabs"; +import { Tabs, TabsContent } from "../../ui/primitives/tabs"; import { useCrawlUrl, useUploadDocument } from "../hooks"; import type { CrawlRequest, UploadMetadata } from "../types"; import { KnowledgeTypeSelector } from "./KnowledgeTypeSelector"; diff --git a/archon-ui-main/src/features/knowledge/components/KnowledgeCard.tsx b/archon-ui-main/src/features/knowledge/components/KnowledgeCard.tsx index d90c0c7e..f935656c 100644 --- a/archon-ui-main/src/features/knowledge/components/KnowledgeCard.tsx +++ b/archon-ui-main/src/features/knowledge/components/KnowledgeCard.tsx @@ -8,12 +8,12 @@ import { format } from "date-fns"; import { motion } from "framer-motion"; import { Briefcase, Clock, Code, ExternalLink, File, FileText, Globe, Terminal } from "lucide-react"; import { useState } from "react"; +import { KnowledgeCardProgress } from "../../progress/components/KnowledgeCardProgress"; +import type { ActiveOperation } from "../../progress/types"; import { StatPill } from "../../ui/primitives"; import { cn } from "../../ui/primitives/styles"; import { SimpleTooltip } from "../../ui/primitives/tooltip"; import { useDeleteKnowledgeItem, useRefreshKnowledgeItem } from "../hooks"; -import { KnowledgeCardProgress } from "../progress/components/KnowledgeCardProgress"; -import type { ActiveOperation } from "../progress/types"; import type { KnowledgeItem } from "../types"; import { extractDomain } from "../utils/knowledge-utils"; import { KnowledgeCardActions } from "./KnowledgeCardActions"; @@ -232,7 +232,7 @@ export const KnowledgeCard: React.FC = ({ @@ -268,7 +268,7 @@ export const KnowledgeCard: React.FC = ({ role="none" className="mt-2" > - + diff --git a/archon-ui-main/src/features/knowledge/components/KnowledgeList.tsx b/archon-ui-main/src/features/knowledge/components/KnowledgeList.tsx index 4850e79a..39d7db41 100644 --- a/archon-ui-main/src/features/knowledge/components/KnowledgeList.tsx +++ b/archon-ui-main/src/features/knowledge/components/KnowledgeList.tsx @@ -5,8 +5,8 @@ import { AnimatePresence, motion } from "framer-motion"; import { AlertCircle, Loader2 } from "lucide-react"; +import type { ActiveOperation } from "../../progress/types"; import { Button } from "../../ui/primitives"; -import type { ActiveOperation } from "../progress/types"; import type { KnowledgeItem } from "../types"; import { KnowledgeCard } from "./KnowledgeCard"; import { KnowledgeTable } from "./KnowledgeTable"; diff --git a/archon-ui-main/src/features/knowledge/hooks/tests/useKnowledgeQueries.test.ts b/archon-ui-main/src/features/knowledge/hooks/tests/useKnowledgeQueries.test.ts index 6e2e29af..630f213a 100644 --- a/archon-ui-main/src/features/knowledge/hooks/tests/useKnowledgeQueries.test.ts +++ b/archon-ui-main/src/features/knowledge/hooks/tests/useKnowledgeQueries.test.ts @@ -60,21 +60,25 @@ describe("useKnowledgeQueries", () => { expect(knowledgeKeys.all).toEqual(["knowledge"]); expect(knowledgeKeys.lists()).toEqual(["knowledge", "list"]); expect(knowledgeKeys.detail("source-123")).toEqual(["knowledge", "detail", "source-123"]); - expect(knowledgeKeys.chunks("source-123", "example.com")).toEqual([ + expect(knowledgeKeys.chunks("source-123", { domain: "example.com" })).toEqual([ "knowledge", - "detail", "source-123", "chunks", - "example.com", + { domain: "example.com", limit: undefined, offset: undefined }, + ]); + expect(knowledgeKeys.codeExamples("source-123")).toEqual([ + "knowledge", + "source-123", + "code-examples", + { limit: undefined, offset: undefined }, ]); - expect(knowledgeKeys.codeExamples("source-123")).toEqual(["knowledge", "detail", "source-123", "code-examples"]); expect(knowledgeKeys.search("test query")).toEqual(["knowledge", "search", "test query"]); expect(knowledgeKeys.sources()).toEqual(["knowledge", "sources"]); }); - it("should handle filter in list key", () => { + it("should handle filter in summaries key", () => { const filter = { knowledge_type: "technical" as const, page: 2 }; - expect(knowledgeKeys.list(filter)).toEqual(["knowledge", "list", filter]); + expect(knowledgeKeys.summaries(filter)).toEqual(["knowledge", "summaries", filter]); }); }); @@ -122,12 +126,22 @@ describe("useKnowledgeQueries", () => { message: "Item deleted", }); - const wrapper = createWrapper(); - const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper }); + // Create QueryClient instance that will be used by the test + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); - // Pre-populate cache - const queryClient = new QueryClient(); - queryClient.setQueryData(knowledgeKeys.list(), initialData); + // Pre-populate cache with the same client instance + queryClient.setQueryData(knowledgeKeys.lists(), initialData); + + // Create wrapper with the pre-populated QueryClient + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper }); await result.current.mutateAsync("source-1"); diff --git a/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts b/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts index 79e5aea1..3c9c61d8 100644 --- a/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts +++ b/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts @@ -5,11 +5,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; +import { useActiveOperations } from "../../progress/hooks"; +import { progressKeys } from "../../progress/hooks/useProgressQueries"; +import type { ActiveOperation, ActiveOperationsResponse } from "../../progress/types"; +import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns"; import { useSmartPolling } from "../../ui/hooks"; import { useToast } from "../../ui/hooks/useToast"; -import { useActiveOperations } from "../progress/hooks"; -import { progressKeys } from "../progress/hooks/useProgressQueries"; -import type { ActiveOperation, ActiveOperationsResponse } from "../progress/types"; import { knowledgeService } from "../services"; import type { CrawlRequest, @@ -25,16 +26,26 @@ import { getProviderErrorMessage } from "../utils/providerErrorHandler"; export const knowledgeKeys = { all: ["knowledge"] as const, lists: () => [...knowledgeKeys.all, "list"] as const, - list: (filters?: KnowledgeItemsFilter) => [...knowledgeKeys.lists(), filters] as const, - details: () => [...knowledgeKeys.all, "detail"] as const, - detail: (sourceId: string) => [...knowledgeKeys.details(), sourceId] as const, - chunks: (sourceId: string, domainFilter?: string) => - [...knowledgeKeys.detail(sourceId), "chunks", domainFilter] as const, - codeExamples: (sourceId: string) => [...knowledgeKeys.detail(sourceId), "code-examples"] as const, - search: (query: string) => [...knowledgeKeys.all, "search", query] as const, + detail: (id: string) => [...knowledgeKeys.all, "detail", id] as const, + // Include domain + pagination to avoid cache collisions + chunks: ( + id: string, + opts?: { domain?: string; limit?: number; offset?: number }, + ) => + [ + ...knowledgeKeys.all, + id, + "chunks", + { domain: opts?.domain ?? "all", limit: opts?.limit, offset: opts?.offset }, + ] as const, + // Include pagination in the key + codeExamples: (id: string, opts?: { limit?: number; offset?: number }) => + [...knowledgeKeys.all, id, "code-examples", { limit: opts?.limit, offset: opts?.offset }] as const, + // Prefix helper for targeting all summaries queries + summariesPrefix: () => [...knowledgeKeys.all, "summaries"] as const, + summaries: (filter?: KnowledgeItemsFilter) => [...knowledgeKeys.all, "summaries", filter] as const, sources: () => [...knowledgeKeys.all, "sources"] as const, - summary: () => [...knowledgeKeys.all, "summary"] as const, - summaries: (filter?: KnowledgeItemsFilter) => [...knowledgeKeys.summary(), filter] as const, + search: (query: string) => [...knowledgeKeys.all, "search", query] as const, }; /** @@ -42,23 +53,34 @@ export const knowledgeKeys = { */ export function useKnowledgeItem(sourceId: string | null) { return useQuery({ - queryKey: sourceId ? knowledgeKeys.detail(sourceId) : ["knowledge-undefined"], + queryKey: sourceId ? knowledgeKeys.detail(sourceId) : DISABLED_QUERY_KEY, queryFn: () => (sourceId ? knowledgeService.getKnowledgeItem(sourceId) : Promise.reject("No source ID")), enabled: !!sourceId, - staleTime: 30000, // Cache for 30 seconds + staleTime: STALE_TIMES.normal, }); } /** * Fetch document chunks for a knowledge item */ -export function useKnowledgeItemChunks(sourceId: string | null, domainFilter?: string) { +export function useKnowledgeItemChunks( + sourceId: string | null, + opts?: { domain?: string; limit?: number; offset?: number } +) { + // TODO: Phase 4 - Add explicit typing: useQuery or appropriate return type + // See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication return useQuery({ - queryKey: sourceId ? knowledgeKeys.chunks(sourceId, domainFilter) : ["chunks-undefined"], + queryKey: sourceId ? knowledgeKeys.chunks(sourceId, opts) : DISABLED_QUERY_KEY, queryFn: () => - sourceId ? knowledgeService.getKnowledgeItemChunks(sourceId, { domainFilter }) : Promise.reject("No source ID"), + sourceId + ? knowledgeService.getKnowledgeItemChunks(sourceId, { + domainFilter: opts?.domain, + limit: opts?.limit, + offset: opts?.offset, + }) + : Promise.reject("No source ID"), enabled: !!sourceId, - staleTime: 60000, // Cache for 1 minute + staleTime: STALE_TIMES.normal, }); } @@ -67,10 +89,10 @@ export function useKnowledgeItemChunks(sourceId: string | null, domainFilter?: s */ export function useCodeExamples(sourceId: string | null) { return useQuery({ - queryKey: sourceId ? knowledgeKeys.codeExamples(sourceId) : ["code-examples-undefined"], + queryKey: sourceId ? knowledgeKeys.codeExamples(sourceId) : DISABLED_QUERY_KEY, queryFn: () => (sourceId ? knowledgeService.getCodeExamples(sourceId) : Promise.reject("No source ID")), enabled: !!sourceId, - staleTime: 60000, // Cache for 1 minute + staleTime: STALE_TIMES.normal, }); } @@ -96,17 +118,25 @@ export function useCrawlUrl() { >({ mutationFn: (request: CrawlRequest) => knowledgeService.crawlUrl(request), onMutate: async (request) => { + // TODO: Phase 3 - Fix optimistic updates writing to wrong cache + // knowledgeKeys.lists() is never queried - actual data comes from knowledgeKeys.summaries(filter) + // This makes all optimistic updates invisible. Should either: + // 1. Remove optimistic updates for knowledge items + // 2. Update all summary caches with optimistic data + // 3. Create a real query that uses lists() + // See: PRPs/local/frontend-state-management-refactor.md Phase 3 + // Cancel any outgoing refetches to prevent race conditions await queryClient.cancelQueries({ queryKey: knowledgeKeys.lists() }); - await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() }); - await queryClient.cancelQueries({ queryKey: progressKeys.list() }); + await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() }); + await queryClient.cancelQueries({ queryKey: progressKeys.active() }); // Snapshot the previous values for rollback const previousKnowledge = queryClient.getQueryData(knowledgeKeys.lists()); const previousSummaries = queryClient.getQueriesData({ - queryKey: knowledgeKeys.summary(), + queryKey: knowledgeKeys.summariesPrefix(), }); - const previousOperations = queryClient.getQueryData(progressKeys.list()); + const previousOperations = queryClient.getQueryData(progressKeys.active()); // Generate temporary IDs const tempProgressId = `temp-progress-${Date.now()}`; @@ -115,7 +145,13 @@ export function useCrawlUrl() { // Create optimistic knowledge item const optimisticItem: KnowledgeItem = { id: tempItemId, - title: new URL(request.url).hostname || "New crawl", + title: (() => { + try { + return new URL(request.url).hostname || "New crawl"; + } catch { + return "New crawl"; + } + })(), url: request.url, source_id: tempProgressId, source_type: "url", @@ -143,7 +179,12 @@ export function useCrawlUrl() { // CRITICAL: Also add optimistic item to SUMMARIES cache (what the UI actually uses!) // This ensures the card shows up immediately in the knowledge base view - queryClient.setQueriesData({ queryKey: knowledgeKeys.summary() }, (old) => { + // TODO: [Phase 3 - Optimistic Updates] Fix filter-blind optimistic updates + // Currently adds items to ALL summary caches regardless of their filters (e.g., knowledge_type, tags). + // This can cause items to appear in filtered views where they shouldn't be visible. + // Solution: Check each cache's filter criteria before adding the optimistic item. + // See: https://github.com/coleam00/Archon/pull/676#issuecomment-XXXXX + queryClient.setQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => { if (!old) { return { items: [optimisticItem], @@ -175,7 +216,7 @@ export function useCrawlUrl() { }; // Add optimistic operation to active operations - queryClient.setQueryData(progressKeys.list(), (old) => { + queryClient.setQueryData(progressKeys.active(), (old) => { if (!old) { return { operations: [optimisticOperation], @@ -213,7 +254,7 @@ export function useCrawlUrl() { }); // Also update summaries cache with real progress ID - queryClient.setQueriesData({ queryKey: knowledgeKeys.summary() }, (old) => { + queryClient.setQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => { if (!old) return old; return { ...old, @@ -230,7 +271,7 @@ export function useCrawlUrl() { }); // Update progress operation with real progress ID - queryClient.setQueryData(progressKeys.list(), (old) => { + queryClient.setQueryData(progressKeys.active(), (old) => { if (!old) return old; return { ...old, @@ -252,7 +293,7 @@ export function useCrawlUrl() { // Invalidate to get fresh data queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }); - queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + queryClient.invalidateQueries({ queryKey: progressKeys.active() }); showToast(`Crawl started: ${response.message}`, "success"); @@ -271,7 +312,7 @@ export function useCrawlUrl() { } } if (context?.previousOperations) { - queryClient.setQueryData(progressKeys.list(), context.previousOperations); + queryClient.setQueryData(progressKeys.active(), context.previousOperations); } const errorMessage = getProviderErrorMessage(error) || "Failed to start crawl"; @@ -302,14 +343,14 @@ export function useUploadDocument() { knowledgeService.uploadDocument(file, metadata), onMutate: async ({ file, metadata }) => { // Cancel any outgoing refetches to prevent race conditions - await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() }); - await queryClient.cancelQueries({ queryKey: progressKeys.list() }); + await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() }); + await queryClient.cancelQueries({ queryKey: progressKeys.active() }); // Snapshot the previous values for rollback const previousSummaries = queryClient.getQueriesData({ - queryKey: knowledgeKeys.summary(), + queryKey: knowledgeKeys.summariesPrefix(), }); - const previousOperations = queryClient.getQueryData(progressKeys.list()); + const previousOperations = queryClient.getQueryData(progressKeys.active()); // Generate temporary IDs const tempProgressId = `temp-upload-${Date.now()}`; @@ -332,14 +373,18 @@ export function useUploadDocument() { source_type: "file", status: "processing", description: `Uploading ${file.name}`, - filename: file.name, + file_name: file.name, }, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; // Add optimistic item to SUMMARIES cache (what the UI uses!) - queryClient.setQueriesData({ queryKey: knowledgeKeys.summary() }, (old) => { + // TODO: [Phase 3 - Optimistic Updates] Fix filter-blind optimistic updates for uploads + // Same issue as crawlUrl - adds items to ALL summary caches regardless of filters. + // Should check filter criteria (knowledge_type, tags, etc.) before adding to each cache. + // See: https://github.com/coleam00/Archon/pull/676#issuecomment-XXXXX + queryClient.setQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => { if (!old) { return { items: [optimisticItem], @@ -371,7 +416,7 @@ export function useUploadDocument() { }; // Add optimistic operation to active operations - queryClient.setQueryData(progressKeys.list(), (old) => { + queryClient.setQueryData(progressKeys.active(), (old) => { if (!old) { return { operations: [optimisticOperation], @@ -392,7 +437,7 @@ export function useUploadDocument() { // Replace temporary IDs with real ones from the server if (context && response?.progressId) { // Update summaries cache with real progress ID - queryClient.setQueriesData({ queryKey: knowledgeKeys.summary() }, (old) => { + queryClient.setQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => { if (!old) return old; return { ...old, @@ -409,7 +454,7 @@ export function useUploadDocument() { }); // Update progress operation with real progress ID - queryClient.setQueryData(progressKeys.list(), (old) => { + queryClient.setQueryData(progressKeys.active(), (old) => { if (!old) return old; return { ...old, @@ -432,8 +477,8 @@ export function useUploadDocument() { // Invalidate queries to get fresh data - with a short delay for fast uploads setTimeout(() => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }); - queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() }); - queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() }); + queryClient.invalidateQueries({ queryKey: progressKeys.active() }); }, 1000); // Don't show success here - upload is just starting in background @@ -447,7 +492,7 @@ export function useUploadDocument() { } } if (context?.previousOperations) { - queryClient.setQueryData(progressKeys.list(), context.previousOperations); + queryClient.setQueryData(progressKeys.active(), context.previousOperations); } // Display the actual error message from backend @@ -470,6 +515,8 @@ export function useStopCrawl() { }, onError: (error, progressId) => { // If it's a 404, the operation might have already completed or been cancelled + // TODO: Phase 4 - Improve error type safety, create proper error interface instead of 'as any' + // See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication const is404Error = (error as any)?.statusCode === 404 || (error instanceof Error && (error.message.includes("404") || error.message.includes("not found"))); @@ -496,10 +543,10 @@ export function useDeleteKnowledgeItem() { mutationFn: (sourceId: string) => knowledgeService.deleteKnowledgeItem(sourceId), onMutate: async (sourceId) => { // Cancel summary queries (all filters) - await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() }); + await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() }); // Snapshot all summary caches (for all filters) - const summariesPrefix = knowledgeKeys.summary(); + const summariesPrefix = knowledgeKeys.summariesPrefix(); const previousEntries = queryClient.getQueriesData({ queryKey: summariesPrefix, }); @@ -529,9 +576,9 @@ export function useDeleteKnowledgeItem() { showToast(data.message || "Item deleted successfully", "success"); // Invalidate summaries to reconcile with server - queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() }); - // Also invalidate detail view if it exists - queryClient.invalidateQueries({ queryKey: knowledgeKeys.details() }); + queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() }); + // Also invalidate detail views + queryClient.invalidateQueries({ queryKey: knowledgeKeys.all }); }, }); } @@ -544,16 +591,16 @@ export function useUpdateKnowledgeItem() { const { showToast } = useToast(); return useMutation({ - mutationFn: ({ sourceId, updates }: { sourceId: string; updates: Partial }) => + mutationFn: ({ sourceId, updates }: { sourceId: string; updates: Partial & { tags?: string[] } }) => knowledgeService.updateKnowledgeItem(sourceId, updates), onMutate: async ({ sourceId, updates }) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: knowledgeKeys.detail(sourceId) }); - await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() }); + await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() }); // Snapshot the previous values const previousItem = queryClient.getQueryData(knowledgeKeys.detail(sourceId)); - const previousSummaries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summary() }); + const previousSummaries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }); // Optimistically update the detail item if (previousItem) { @@ -567,30 +614,31 @@ export function useUpdateKnowledgeItem() { updatedItem.title = updates.title; } - // Handle tags updates + // Handle tags updates - update in metadata only if ("tags" in updates && Array.isArray(updates.tags)) { const newTags = updates.tags as string[]; - (updatedItem as any).tags = newTags; + updatedItem.metadata = { + ...currentMetadata, + tags: newTags, + }; } // Handle knowledge_type updates if ("knowledge_type" in updates && typeof updates.knowledge_type === "string") { const newType = updates.knowledge_type as "technical" | "business"; updatedItem.knowledge_type = newType; + // Also update in metadata for consistency + updatedItem.metadata = { + ...updatedItem.metadata, + knowledge_type: newType, + }; } - // Synchronize metadata with top-level fields - updatedItem.metadata = { - ...currentMetadata, - ...((updatedItem as any).tags && { tags: (updatedItem as any).tags }), - ...(updatedItem.knowledge_type && { knowledge_type: updatedItem.knowledge_type }), - }; - queryClient.setQueryData(knowledgeKeys.detail(sourceId), updatedItem); } // Optimistically update summaries cache - queryClient.setQueriesData({ queryKey: knowledgeKeys.summary() }, (old) => { + queryClient.setQueriesData({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => { if (!old?.items) return old; return { @@ -607,25 +655,26 @@ export function useUpdateKnowledgeItem() { updatedItem.title = updates.title; } - // Update tags if provided + // Update tags if provided - update in metadata only if ("tags" in updates && Array.isArray(updates.tags)) { const newTags = updates.tags as string[]; - updatedItem.tags = newTags; + updatedItem.metadata = { + ...currentMetadata, + tags: newTags, + }; } // Update knowledge_type if provided if ("knowledge_type" in updates && typeof updates.knowledge_type === "string") { const newType = updates.knowledge_type as "technical" | "business"; updatedItem.knowledge_type = newType; + // Also update in metadata for consistency + updatedItem.metadata = { + ...updatedItem.metadata, + knowledge_type: newType, + }; } - // Synchronize metadata with top-level fields - updatedItem.metadata = { - ...currentMetadata, - ...(updatedItem.tags && { tags: updatedItem.tags }), - ...(updatedItem.knowledge_type && { knowledge_type: updatedItem.knowledge_type }), - }; - return updatedItem; } return item; @@ -656,7 +705,7 @@ export function useUpdateKnowledgeItem() { // Invalidate all related queries queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(sourceId) }); queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }); - queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() }); // Add summaries cache + queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() }); // Add summaries cache }, }); } @@ -722,14 +771,16 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) { }, [activeOperationsData]); // Fetch summaries with smart polling when there are active operations - const { refetchInterval } = useSmartPolling(hasActiveOperations ? 5000 : 30000); + const { refetchInterval } = useSmartPolling( + hasActiveOperations ? STALE_TIMES.frequent : STALE_TIMES.normal, + ); const summaryQuery = useQuery({ queryKey: knowledgeKeys.summaries(filter), queryFn: () => knowledgeService.getKnowledgeSummaries(filter), refetchInterval: hasActiveOperations ? refetchInterval : false, // Poll when ANY operations are active refetchOnWindowFocus: true, - staleTime: 30000, // Consider data stale after 30 seconds + staleTime: STALE_TIMES.normal, // Consider data stale after 30 seconds }); // When operations complete, remove them from tracking @@ -751,13 +802,13 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) { // Invalidate after a delay to allow backend database to become consistent const timer = setTimeout(() => { // Invalidate all summaries regardless of filter - queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() }); + queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() }); // Also invalidate lists for consistency queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }); // For uploads, also refetch immediately to ensure UI shows the item if (hasCompletedUpload) { - queryClient.refetchQueries({ queryKey: knowledgeKeys.summary() }); + queryClient.refetchQueries({ queryKey: knowledgeKeys.summariesPrefix() }); } }, delay); @@ -782,8 +833,8 @@ export function useKnowledgeChunks( ) { return useQuery({ queryKey: sourceId - ? [...knowledgeKeys.detail(sourceId), "chunks", options?.limit, options?.offset] - : ["chunks-undefined"], + ? knowledgeKeys.chunks(sourceId, { limit: options?.limit, offset: options?.offset }) + : DISABLED_QUERY_KEY, queryFn: () => sourceId ? knowledgeService.getKnowledgeItemChunks(sourceId, { @@ -792,7 +843,7 @@ export function useKnowledgeChunks( }) : Promise.reject("No source ID"), enabled: options?.enabled !== false && !!sourceId, - staleTime: 60000, // Cache for 1 minute + staleTime: STALE_TIMES.normal, }); } @@ -805,8 +856,8 @@ export function useKnowledgeCodeExamples( ) { return useQuery({ queryKey: sourceId - ? [...knowledgeKeys.codeExamples(sourceId), options?.limit, options?.offset] - : ["code-examples-undefined"], + ? knowledgeKeys.codeExamples(sourceId, { limit: options?.limit, offset: options?.offset }) + : DISABLED_QUERY_KEY, queryFn: () => sourceId ? knowledgeService.getCodeExamples(sourceId, { @@ -815,6 +866,6 @@ export function useKnowledgeCodeExamples( }) : Promise.reject("No source ID"), enabled: options?.enabled !== false && !!sourceId, - staleTime: 60000, // Cache for 1 minute + staleTime: STALE_TIMES.normal, }); } diff --git a/archon-ui-main/src/features/knowledge/index.ts b/archon-ui-main/src/features/knowledge/index.ts index f454bd7f..5cd5b78f 100644 --- a/archon-ui-main/src/features/knowledge/index.ts +++ b/archon-ui-main/src/features/knowledge/index.ts @@ -5,7 +5,6 @@ * - Knowledge item management (CRUD, search) * - Crawling and URL processing * - Document upload and processing - * - Progress tracking for long-running operations * - Document browsing and viewing */ @@ -13,8 +12,6 @@ export * from "./components"; // Hooks export * from "./hooks"; -// Sub-features -export * from "./progress"; // Services export * from "./services"; // Types diff --git a/archon-ui-main/src/features/knowledge/services/knowledgeService.ts b/archon-ui-main/src/features/knowledge/services/knowledgeService.ts index feb21d72..b9d6af06 100644 --- a/archon-ui-main/src/features/knowledge/services/knowledgeService.ts +++ b/archon-ui-main/src/features/knowledge/services/knowledgeService.ts @@ -65,7 +65,10 @@ export const knowledgeService = { /** * Update a knowledge item */ - async updateKnowledgeItem(sourceId: string, updates: Partial): Promise { + async updateKnowledgeItem( + sourceId: string, + updates: Partial & { tags?: string[] }, + ): Promise { const response = await callAPIWithETag(`/api/knowledge-items/${sourceId}`, { method: "PUT", body: JSON.stringify(updates), diff --git a/archon-ui-main/src/features/knowledge/types/index.ts b/archon-ui-main/src/features/knowledge/types/index.ts index 3241dcc7..593c16f9 100644 --- a/archon-ui-main/src/features/knowledge/types/index.ts +++ b/archon-ui-main/src/features/knowledge/types/index.ts @@ -1,2 +1 @@ -export * from "../progress/types"; export * from "./knowledge"; diff --git a/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx b/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx index b6960c8a..20d43650 100644 --- a/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx +++ b/archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx @@ -4,14 +4,14 @@ */ import { useEffect, useMemo, useRef, useState } from "react"; +import { CrawlingProgress } from "../../progress/components/CrawlingProgress"; +import type { ActiveOperation } from "../../progress/types"; import { useToast } from "../../ui/hooks/useToast"; import { AddKnowledgeDialog } from "../components/AddKnowledgeDialog"; import { KnowledgeHeader } from "../components/KnowledgeHeader"; import { KnowledgeList } from "../components/KnowledgeList"; import { useKnowledgeSummaries } from "../hooks/useKnowledgeQueries"; import { KnowledgeInspector } from "../inspector/components/KnowledgeInspector"; -import { CrawlingProgress } from "../progress/components/CrawlingProgress"; -import type { ActiveOperation } from "../progress/types"; import type { KnowledgeItem, KnowledgeItemsFilter } from "../types"; export const KnowledgeView = () => { diff --git a/archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts b/archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts index 11954fe2..409694f5 100644 --- a/archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts +++ b/archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { STALE_TIMES } from "../../shared/queryPatterns"; import { useSmartPolling } from "../../ui/hooks"; import { mcpApi } from "../services"; @@ -9,6 +10,7 @@ export const mcpKeys = { config: () => [...mcpKeys.all, "config"] as const, sessions: () => [...mcpKeys.all, "sessions"] as const, clients: () => [...mcpKeys.all, "clients"] as const, + health: () => [...mcpKeys.all, "health"] as const, }; export function useMcpStatus() { @@ -19,7 +21,7 @@ export function useMcpStatus() { queryFn: () => mcpApi.getStatus(), refetchInterval, refetchOnWindowFocus: false, - staleTime: 3000, + staleTime: STALE_TIMES.frequent, throwOnError: true, }); } @@ -28,7 +30,7 @@ export function useMcpConfig() { return useQuery({ queryKey: mcpKeys.config(), queryFn: () => mcpApi.getConfig(), - staleTime: Infinity, // Config rarely changes + staleTime: STALE_TIMES.static, // Config rarely changes throwOnError: true, }); } @@ -41,7 +43,7 @@ export function useMcpClients() { queryFn: () => mcpApi.getClients(), refetchInterval, refetchOnWindowFocus: false, - staleTime: 8000, + staleTime: STALE_TIMES.frequent, throwOnError: true, }); } @@ -54,7 +56,7 @@ export function useMcpSessionInfo() { queryFn: () => mcpApi.getSessionInfo(), refetchInterval, refetchOnWindowFocus: false, - staleTime: 8000, + staleTime: STALE_TIMES.frequent, throwOnError: true, }); } diff --git a/archon-ui-main/src/features/knowledge/progress/components/CrawlingProgress.tsx b/archon-ui-main/src/features/progress/components/CrawlingProgress.tsx similarity index 98% rename from archon-ui-main/src/features/knowledge/progress/components/CrawlingProgress.tsx rename to archon-ui-main/src/features/progress/components/CrawlingProgress.tsx index 96a0821c..ca03ecfb 100644 --- a/archon-ui-main/src/features/knowledge/progress/components/CrawlingProgress.tsx +++ b/archon-ui-main/src/features/progress/components/CrawlingProgress.tsx @@ -7,9 +7,9 @@ import { AnimatePresence, motion } from "framer-motion"; import { AlertCircle, CheckCircle, Globe, Loader2, StopCircle, XCircle } from "lucide-react"; import { useState } from "react"; -import { Button } from "../../../ui/primitives"; -import { cn } from "../../../ui/primitives/styles"; -import { useStopCrawl } from "../../hooks"; +import { useStopCrawl } from "../../knowledge/hooks"; +import { Button } from "../../ui/primitives"; +import { cn } from "../../ui/primitives/styles"; import { useCrawlProgressPolling } from "../hooks"; import type { ActiveOperation } from "../types/progress"; diff --git a/archon-ui-main/src/features/knowledge/progress/components/KnowledgeCardProgress.tsx b/archon-ui-main/src/features/progress/components/KnowledgeCardProgress.tsx similarity index 98% rename from archon-ui-main/src/features/knowledge/progress/components/KnowledgeCardProgress.tsx rename to archon-ui-main/src/features/progress/components/KnowledgeCardProgress.tsx index 69db53a7..eb889bd0 100644 --- a/archon-ui-main/src/features/knowledge/progress/components/KnowledgeCardProgress.tsx +++ b/archon-ui-main/src/features/progress/components/KnowledgeCardProgress.tsx @@ -6,7 +6,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { AlertCircle, CheckCircle2, Code, FileText, Link, Loader2 } from "lucide-react"; -import { cn } from "../../../ui/primitives/styles"; +import { cn } from "../../ui/primitives/styles"; import type { ActiveOperation } from "../types/progress"; interface KnowledgeCardProgressProps { diff --git a/archon-ui-main/src/features/knowledge/progress/components/index.ts b/archon-ui-main/src/features/progress/components/index.ts similarity index 100% rename from archon-ui-main/src/features/knowledge/progress/components/index.ts rename to archon-ui-main/src/features/progress/components/index.ts diff --git a/archon-ui-main/src/features/knowledge/progress/hooks/index.ts b/archon-ui-main/src/features/progress/hooks/index.ts similarity index 100% rename from archon-ui-main/src/features/knowledge/progress/hooks/index.ts rename to archon-ui-main/src/features/progress/hooks/index.ts diff --git a/archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts b/archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts new file mode 100644 index 00000000..565919aa --- /dev/null +++ b/archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts @@ -0,0 +1,260 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ActiveOperationsResponse, ProgressResponse } from "../../types"; +import { + progressKeys, + useActiveOperations, + useCrawlProgressPolling, + useOperationProgress, +} from "../useProgressQueries"; + +// Mock the services +vi.mock("../../services", () => ({ + progressService: { + getProgress: vi.fn(), + listActiveOperations: vi.fn(), + }, +})); + +// Mock shared query patterns +vi.mock("../../../shared/queryPatterns", () => ({ + DISABLED_QUERY_KEY: ["disabled"] as const, + STALE_TIMES: { + instant: 0, + realtime: 3_000, + frequent: 5_000, + normal: 30_000, + rare: 300_000, + static: Infinity, + }, +})); + +// Test wrapper with QueryClient +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe("useProgressQueries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("progressKeys", () => { + it("should generate correct query keys", () => { + expect(progressKeys.all).toEqual(["progress"]); + expect(progressKeys.lists()).toEqual(["progress", "list"]); + expect(progressKeys.detail("progress-123")).toEqual(["progress", "detail", "progress-123"]); + expect(progressKeys.active()).toEqual(["progress", "active"]); + }); + }); + + describe("useOperationProgress", () => { + it("should poll for progress when progressId is provided", async () => { + const mockProgress: ProgressResponse = { + progressId: "progress-123", + status: "processing", + message: "Processing...", + progress: 50, + details: {}, + }; + + const { progressService } = await import("../../services"); + vi.mocked(progressService.getProgress).mockResolvedValue(mockProgress); + + const { result } = renderHook(() => useOperationProgress("progress-123"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual(mockProgress); + expect(progressService.getProgress).toHaveBeenCalledWith("progress-123"); + }); + }); + + it("should call onComplete callback when operation completes", async () => { + const onComplete = vi.fn(); + const completedProgress: ProgressResponse = { + progressId: "progress-123", + status: "completed", + message: "Completed", + progress: 100, + details: { result: "success" }, + }; + + const { progressService } = await import("../../services"); + vi.mocked(progressService.getProgress).mockResolvedValue(completedProgress); + + const { result } = renderHook(() => useOperationProgress("progress-123", { onComplete }), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data?.status).toBe("completed"); + expect(onComplete).toHaveBeenCalledWith(completedProgress); + }); + }); + + it("should call onError callback when operation fails", async () => { + const onError = vi.fn(); + const errorProgress: ProgressResponse = { + progressId: "progress-123", + status: "error", + message: "Failed to process", + progress: 0, + error: "Something went wrong", + }; + + const { progressService } = await import("../../services"); + vi.mocked(progressService.getProgress).mockResolvedValue(errorProgress); + + const { result } = renderHook(() => useOperationProgress("progress-123", { onError }), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data?.status).toBe("error"); + // onError is called with just the error string, not the full response + expect(onError).toHaveBeenCalledWith("Something went wrong"); + }); + }); + + it("should not execute query when progressId is null", () => { + const { result } = renderHook(() => useOperationProgress(null), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + }); + + describe("useActiveOperations", () => { + it("should fetch active operations when enabled", async () => { + const mockOperations: ActiveOperationsResponse = { + operations: [ + { + progressId: "op-1", + sourceId: "source-1", + status: "processing", + message: "Processing document", + progress: 30, + }, + { + progressId: "op-2", + sourceId: "source-2", + status: "processing", + message: "Crawling website", + progress: 60, + }, + ], + }; + + const { progressService } = await import("../../services"); + vi.mocked(progressService.listActiveOperations).mockResolvedValue(mockOperations); + + const { result } = renderHook(() => useActiveOperations(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toEqual(mockOperations); + expect(progressService.listActiveOperations).toHaveBeenCalled(); + }); + }); + + it("should not fetch when disabled", () => { + const { result } = renderHook(() => useActiveOperations(false), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + }); + + describe("useCrawlProgressPolling", () => { + it("should poll for active crawl operations", async () => { + const mockOperations: ActiveOperationsResponse = { + operations: [ + { + progressId: "crawl-1", + sourceId: "source-1", + status: "processing", + message: "Crawling page 1 of 5", + progress: 20, + }, + ], + }; + + const { progressService } = await import("../../services"); + vi.mocked(progressService.listActiveOperations).mockResolvedValue(mockOperations); + + const { result } = renderHook(() => useCrawlProgressPolling(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.activeOperations).toEqual(mockOperations.operations); + }); + }); + + it("should return empty array when no operations", async () => { + const emptyResponse: ActiveOperationsResponse = { + operations: [], + count: 0, + }; + + const { progressService } = await import("../../services"); + vi.mocked(progressService.listActiveOperations).mockResolvedValue(emptyResponse); + + const { result } = renderHook(() => useCrawlProgressPolling(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.activeOperations).toEqual([]); + expect(result.current.totalCount).toBe(0); + }); + }); + + it("should identify active operations correctly", async () => { + const mockOperations: ActiveOperationsResponse = { + operations: [ + { + progressId: "op-1", + sourceId: "source-1", + status: "processing", + message: "Active operation", + progress: 50, + }, + ], + count: 1, + }; + + const { progressService } = await import("../../services"); + vi.mocked(progressService.listActiveOperations).mockResolvedValue(mockOperations); + + const { result } = renderHook(() => useCrawlProgressPolling(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.activeOperations).toHaveLength(1); + expect(result.current.totalCount).toBe(1); + }); + }); + }); +}); diff --git a/archon-ui-main/src/features/knowledge/progress/hooks/useProgressQueries.ts b/archon-ui-main/src/features/progress/hooks/useProgressQueries.ts similarity index 63% rename from archon-ui-main/src/features/knowledge/progress/hooks/useProgressQueries.ts rename to archon-ui-main/src/features/progress/hooks/useProgressQueries.ts index 8d00cf14..19c8e401 100644 --- a/archon-ui-main/src/features/knowledge/progress/hooks/useProgressQueries.ts +++ b/archon-ui-main/src/features/progress/hooks/useProgressQueries.ts @@ -3,16 +3,20 @@ * Handles polling for operation progress with TanStack Query */ -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; +import { type UseQueryResult, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; +import { APIServiceError } from "../../shared/errors"; +import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns"; +import { useSmartPolling } from "../../ui/hooks"; import { progressService } from "../services"; import type { ActiveOperationsResponse, ProgressResponse, ProgressStatus } from "../types"; // Query keys factory export const progressKeys = { all: ["progress"] as const, - detail: (progressId: string) => [...progressKeys.all, progressId] as const, - list: () => [...progressKeys.all, "list"] as const, + lists: () => [...progressKeys.all, "list"] as const, + detail: (id: string) => [...progressKeys.all, "detail", id] as const, + active: () => [...progressKeys.all, "active"] as const, }; // Terminal states that should stop polling @@ -34,6 +38,7 @@ export function useOperationProgress( const hasCalledComplete = useRef(false); const hasCalledError = useRef(false); const consecutiveNotFound = useRef(0); + const { refetchInterval: smartInterval } = useSmartPolling(options?.pollingInterval ?? 1000); // Reset refs when progressId changes useEffect(() => { @@ -43,7 +48,7 @@ export function useOperationProgress( }, [progressId]); const query = useQuery({ - queryKey: progressId ? progressKeys.detail(progressId) : ["progress-undefined"], + queryKey: progressId ? progressKeys.detail(progressId) : DISABLED_QUERY_KEY, queryFn: async () => { if (!progressId) throw new Error("No progress ID"); @@ -52,9 +57,14 @@ export function useOperationProgress( consecutiveNotFound.current = 0; // Reset counter on success return data; } catch (error: unknown) { - // Handle 404 errors specially - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("404") || errorMessage.includes("not found")) { + // Handle 404 errors specially - check status code first, then message as fallback + const isNotFound = + (error instanceof APIServiceError && error.statusCode === 404) || + (error as { status?: number })?.status === 404 || + (error as { response?: { status?: number } })?.response?.status === 404 || + (error instanceof Error && /not found/i.test(error.message)); + + if (isNotFound) { consecutiveNotFound.current++; // After 5 consecutive 404s, assume the operation is gone @@ -79,14 +89,16 @@ export function useOperationProgress( } // Keep polling on undefined (initial), null (transient 404), or active operations - return options?.pollingInterval ?? 1000; + // Use smart interval that pauses when tab is hidden + return smartInterval; }, retry: false, // Don't retry on error - staleTime: 0, // Always refetch + staleTime: STALE_TIMES.instant, // Always fresh for real-time progress }); // Handle completion and error callbacks useEffect(() => { + const timers: ReturnType[] = []; if (!query.data) return; const status = query.data.status; @@ -97,11 +109,13 @@ export function useOperationProgress( options?.onComplete?.(query.data); // Clean up the query after completion - setTimeout(() => { - if (progressId) { - queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) }); - } - }, 2000); + timers.push( + setTimeout(() => { + if (progressId) { + queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true }); + } + }, 2000), + ); } // Handle cancellation @@ -110,11 +124,13 @@ export function useOperationProgress( options?.onError?.(query.data.error || "Operation was cancelled"); // Clean up the query after cancellation - setTimeout(() => { - if (progressId) { - queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) }); - } - }, 2000); + timers.push( + setTimeout(() => { + if (progressId) { + queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true }); + } + }, 2000), + ); } // Handle errors @@ -123,16 +139,24 @@ export function useOperationProgress( options?.onError?.(query.data.error || "Operation failed"); // Clean up the query after error - setTimeout(() => { - if (progressId) { - queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) }); - } - }, 5000); + timers.push( + setTimeout(() => { + if (progressId) { + queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true }); + } + }, 5000), + ); } + + // Cleanup function to clear all timeouts + return () => { + timers.forEach(clearTimeout); + }; }, [query.data?.status, progressId, queryClient, options, query.data]); // Forward query errors (e.g., "Operation no longer exists") to onError callback useEffect(() => { + const timers: ReturnType[] = []; if (!query.error || hasCalledError.current) return; hasCalledError.current = true; @@ -140,11 +164,18 @@ export function useOperationProgress( options?.onError?.(errorMessage); // Clean up the query after error - setTimeout(() => { - if (progressId) { - queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) }); - } - }, 5000); + timers.push( + setTimeout(() => { + if (progressId) { + queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true }); + } + }, 5000), + ); + + // Cleanup function to clear timeouts + return () => { + timers.forEach(clearTimeout); + }; }, [query.error, progressId, queryClient, options]); return { @@ -163,31 +194,27 @@ export function useOperationProgress( * @param enabled - Whether to enable polling (default: false) */ export function useActiveOperations(enabled = false) { + const { refetchInterval } = useSmartPolling(5000); + return useQuery({ - queryKey: progressKeys.list(), + queryKey: progressKeys.active(), queryFn: () => progressService.listActiveOperations(), enabled, - refetchInterval: enabled ? 5000 : false, // Only poll when explicitly enabled - staleTime: 3000, + refetchInterval: enabled ? refetchInterval : false, // Only poll when explicitly enabled, pause when hidden + staleTime: STALE_TIMES.realtime, // Near real-time for active operations }); } /** * Hook for polling all crawl operations * Used in the CrawlingProgress component + * Delegates to useActiveOperations for consistency */ export function useCrawlProgressPolling() { - const { data, isLoading } = useQuery({ - queryKey: progressKeys.list(), - queryFn: () => progressService.listActiveOperations(), - refetchInterval: 5000, // Poll every 5 seconds - staleTime: 0, - }); - - const activeOperations = data?.operations || []; + const { data, isLoading } = useActiveOperations(true); // Always enabled for crawling progress return { - activeOperations, + activeOperations: data?.operations || [], isLoading, totalCount: data?.count || 0, }; @@ -209,26 +236,34 @@ export function useMultipleOperations( const errorIds = useRef(new Set()); // Track consecutive 404s per operation const notFoundCounts = useRef>(new Map()); + const { refetchInterval: smartInterval } = useSmartPolling(1000); // Reset tracking sets when progress IDs change + // Use sorted JSON stringification for stable dependency that handles reordering + const progressIdsKey = useMemo(() => JSON.stringify([...progressIds].sort()), [progressIds]); useEffect(() => { completedIds.current.clear(); errorIds.current.clear(); notFoundCounts.current.clear(); - }, [progressIds.join(",")]); // Use join to create stable dependency + }, [progressIdsKey]); // Stable dependency across reorderings - const queries = (useQueries as any)({ + const queries = useQueries({ queries: progressIds.map((progressId) => ({ - queryKey: progressKeys.detail(progressId) as readonly unknown[], + queryKey: progressKeys.detail(progressId), queryFn: async (): Promise => { try { const data = await progressService.getProgress(progressId); notFoundCounts.current.set(progressId, 0); // Reset counter on success return data; } catch (error: unknown) { - // Handle 404 errors specially for resilience - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("404") || errorMessage.includes("not found")) { + // Handle 404 errors specially for resilience - check status code first + const isNotFound = + (error instanceof APIServiceError && error.statusCode === 404) || + (error as { status?: number })?.status === 404 || + (error as { response?: { status?: number } })?.response?.status === 404 || + (error instanceof Error && /not found/i.test(error.message)); + + if (isNotFound) { const currentCount = (notFoundCounts.current.get(progressId) || 0) + 1; notFoundCounts.current.set(progressId, currentCount); @@ -244,8 +279,8 @@ export function useMultipleOperations( throw error; } }, - refetchInterval: (query: { state: { data?: ProgressResponse } }) => { - const data = query.state.data as ProgressResponse | null | undefined; + refetchInterval: (query: { state: { data: ProgressResponse | null | undefined } }) => { + const data = query.state.data; // Only stop polling when we have actual data and it's in a terminal state if (data && TERMINAL_STATES.includes(data.status)) { @@ -253,16 +288,19 @@ export function useMultipleOperations( } // Keep polling on undefined (initial), null (transient 404), or active operations - return 1000; + // Use smart interval that pauses when tab is hidden + return smartInterval; }, retry: false, - staleTime: 0, + staleTime: STALE_TIMES.instant, // Always fresh for real-time progress })), - }); + }) as UseQueryResult[]; // Handle callbacks for each operation useEffect(() => { - queries.forEach((query: any, index: number) => { + const timers: ReturnType[] = []; + + queries.forEach((query, index) => { const progressId = progressIds[index]; if (!query.data || !progressId) return; @@ -277,9 +315,11 @@ export function useMultipleOperations( options?.onComplete?.(progressId, data); // Clean up after completion - setTimeout(() => { - queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) }); - }, 2000); + timers.push( + setTimeout(() => { + queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true }); + }, 2000), + ); } // Handle errors @@ -288,16 +328,25 @@ export function useMultipleOperations( options?.onError?.(progressId, data.error || "Operation failed"); // Clean up after error - setTimeout(() => { - queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) }); - }, 5000); + timers.push( + setTimeout(() => { + queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true }); + }, 5000), + ); } }); + + // Cleanup function to clear all timeouts + return () => { + timers.forEach(clearTimeout); + }; }, [queries, progressIds, queryClient, options]); // Forward query errors (e.g., 404s after threshold) to onError callback useEffect(() => { - queries.forEach((query: any, index: number) => { + const timers: ReturnType[] = []; + + queries.forEach((query, index) => { const progressId = progressIds[index]; if (!query.error || !progressId || errorIds.current.has(progressId)) return; @@ -306,13 +355,20 @@ export function useMultipleOperations( options?.onError?.(progressId, errorMessage); // Clean up after error - setTimeout(() => { - queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) }); - }, 5000); + timers.push( + setTimeout(() => { + queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true }); + }, 5000), + ); }); + + // Cleanup function to clear all timeouts + return () => { + timers.forEach(clearTimeout); + }; }, [queries, progressIds, queryClient, options]); - return queries.map((query: any, index: number) => { + return queries.map((query, index) => { const data = query.data as ProgressResponse | null; return { progressId: progressIds[index], diff --git a/archon-ui-main/src/features/knowledge/progress/index.ts b/archon-ui-main/src/features/progress/index.ts similarity index 100% rename from archon-ui-main/src/features/knowledge/progress/index.ts rename to archon-ui-main/src/features/progress/index.ts diff --git a/archon-ui-main/src/features/knowledge/progress/services/index.ts b/archon-ui-main/src/features/progress/services/index.ts similarity index 100% rename from archon-ui-main/src/features/knowledge/progress/services/index.ts rename to archon-ui-main/src/features/progress/services/index.ts diff --git a/archon-ui-main/src/features/knowledge/progress/services/progressService.ts b/archon-ui-main/src/features/progress/services/progressService.ts similarity index 91% rename from archon-ui-main/src/features/knowledge/progress/services/progressService.ts rename to archon-ui-main/src/features/progress/services/progressService.ts index fc669f3a..d3f6e61e 100644 --- a/archon-ui-main/src/features/knowledge/progress/services/progressService.ts +++ b/archon-ui-main/src/features/progress/services/progressService.ts @@ -3,7 +3,7 @@ * Uses ETag support for efficient polling */ -import { callAPIWithETag } from "../../../shared/apiWithEtag"; +import { callAPIWithETag } from "../../shared/apiWithEtag"; import type { ActiveOperationsResponse, ProgressResponse } from "../types"; export const progressService = { diff --git a/archon-ui-main/src/features/knowledge/progress/types/index.ts b/archon-ui-main/src/features/progress/types/index.ts similarity index 100% rename from archon-ui-main/src/features/knowledge/progress/types/index.ts rename to archon-ui-main/src/features/progress/types/index.ts diff --git a/archon-ui-main/src/features/knowledge/progress/types/progress.ts b/archon-ui-main/src/features/progress/types/progress.ts similarity index 100% rename from archon-ui-main/src/features/knowledge/progress/types/progress.ts rename to archon-ui-main/src/features/progress/types/progress.ts diff --git a/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts b/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts index fc9ba525..0a7d23ee 100644 --- a/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts +++ b/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts @@ -1,10 +1,16 @@ import { useQuery } from "@tanstack/react-query"; +import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns"; import { projectService } from "../../services"; import type { ProjectDocument } from "../types"; -// Query keys -const documentKeys = { - all: (projectId: string) => ["projects", projectId, "documents"] as const, +// Query keys factory for documents +export const documentKeys = { + all: ["documents"] as const, + byProject: (projectId: string) => ["projects", projectId, "documents"] as const, + detail: (projectId: string, docId: string) => ["projects", projectId, "documents", "detail", docId] as const, + versions: (projectId: string) => ["projects", projectId, "versions"] as const, + version: (projectId: string, fieldName: string, version: number) => + ["projects", projectId, "versions", fieldName, version] as const, }; /** @@ -13,12 +19,13 @@ const documentKeys = { */ export function useProjectDocuments(projectId: string | undefined) { return useQuery({ - queryKey: projectId ? documentKeys.all(projectId) : ["documents-undefined"], + queryKey: projectId ? documentKeys.byProject(projectId) : DISABLED_QUERY_KEY, queryFn: async () => { if (!projectId) return []; const project = await projectService.getProject(projectId); return (project.docs || []) as ProjectDocument[]; }, enabled: !!projectId, + staleTime: STALE_TIMES.normal, }); } diff --git a/archon-ui-main/src/features/projects/hooks/index.ts b/archon-ui-main/src/features/projects/hooks/index.ts index 14f21d05..3834d88a 100644 --- a/archon-ui-main/src/features/projects/hooks/index.ts +++ b/archon-ui-main/src/features/projects/hooks/index.ts @@ -15,6 +15,5 @@ export { useDeleteProject, useProjectFeatures, useProjects, - useTaskCounts, useUpdateProject, } from "./useProjectQueries"; diff --git a/archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts b/archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts index 98bd984d..1ad07cf4 100644 --- a/archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts +++ b/archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts @@ -57,9 +57,7 @@ describe("useProjectQueries", () => { expect(projectKeys.all).toEqual(["projects"]); expect(projectKeys.lists()).toEqual(["projects", "list"]); expect(projectKeys.detail("123")).toEqual(["projects", "detail", "123"]); - expect(projectKeys.tasks("123")).toEqual(["projects", "detail", "123", "tasks"]); - expect(projectKeys.features("123")).toEqual(["projects", "detail", "123", "features"]); - expect(projectKeys.documents("123")).toEqual(["projects", "detail", "123", "documents"]); + expect(projectKeys.features("123")).toEqual(["projects", "123", "features"]); }); }); diff --git a/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts b/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts index 3da99ec2..bafdca52 100644 --- a/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts +++ b/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts @@ -1,20 +1,18 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns"; import { useSmartPolling } from "../../ui/hooks"; import { useToast } from "../../ui/hooks/useToast"; -import { projectService, taskService } from "../services"; +import { projectService } from "../services"; import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../types"; // Query keys factory for better organization export const projectKeys = { all: ["projects"] as const, lists: () => [...projectKeys.all, "list"] as const, - list: (filters?: unknown) => [...projectKeys.lists(), filters] as const, - details: () => [...projectKeys.all, "detail"] as const, - detail: (id: string) => [...projectKeys.details(), id] as const, - tasks: (projectId: string) => [...projectKeys.detail(projectId), "tasks"] as const, - taskCounts: () => ["taskCounts"] as const, - features: (projectId: string) => [...projectKeys.detail(projectId), "features"] as const, - documents: (projectId: string) => [...projectKeys.detail(projectId), "documents"] as const, + detail: (id: string) => [...projectKeys.all, "detail", id] as const, + features: (id: string) => [...projectKeys.all, id, "features"] as const, + // Documents keys moved to documentKeys in documents feature + // Tasks keys moved to taskKeys in tasks feature }; // Fetch all projects with smart polling @@ -26,27 +24,19 @@ export function useProjects() { queryFn: () => projectService.listProjects(), refetchInterval, // Smart interval based on page visibility/focus refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap) - staleTime: 15000, // Consider data stale after 15 seconds - }); -} - -// Fetch task counts for all projects -export function useTaskCounts() { - return useQuery>>({ - queryKey: projectKeys.taskCounts(), - queryFn: () => taskService.getTaskCountsForAllProjects(), - refetchInterval: false, // Don't poll, only refetch manually - staleTime: 5 * 60 * 1000, // Cache for 5 minutes + staleTime: STALE_TIMES.normal, }); } // Fetch project features export function useProjectFeatures(projectId: string | undefined) { + // TODO: Phase 4 - Add explicit typing: useQuery>> + // See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication return useQuery({ - queryKey: projectId ? projectKeys.features(projectId) : ["features-undefined"], + queryKey: projectId ? projectKeys.features(projectId) : DISABLED_QUERY_KEY, queryFn: () => (projectId ? projectService.getProjectFeatures(projectId) : Promise.reject("No project ID")), enabled: !!projectId, - staleTime: 30000, // Cache for 30 seconds + staleTime: STALE_TIMES.normal, }); } diff --git a/archon-ui-main/src/features/projects/tasks/components/EditableTableCell.tsx b/archon-ui-main/src/features/projects/tasks/components/EditableTableCell.tsx index cde92f74..a6352077 100644 --- a/archon-ui-main/src/features/projects/tasks/components/EditableTableCell.tsx +++ b/archon-ui-main/src/features/projects/tasks/components/EditableTableCell.tsx @@ -1,7 +1,17 @@ import type React from "react"; import { useEffect, useRef, useState } from "react"; -import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/primitives"; +import { + ComboBox, + type ComboBoxOption, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../ui/primitives"; import { cn } from "../../../ui/primitives/styles"; +import { COMMON_ASSIGNEES } from "../types"; interface EditableTableCellProps { value: string; @@ -16,8 +26,11 @@ interface EditableTableCellProps { // Status options for the status select const STATUS_OPTIONS = ["todo", "doing", "review", "done"] as const; -// Assignee options -const ASSIGNEE_OPTIONS = ["User", "Archon", "AI IDE Agent"] as const; +// Convert common assignees to ComboBox options +const ASSIGNEE_OPTIONS: ComboBoxOption[] = COMMON_ASSIGNEES.map((name) => ({ + value: name, + label: name, +})); export const EditableTableCell = ({ value, @@ -81,7 +94,7 @@ export const EditableTableCell = ({ }; // Get the appropriate options based on type - const selectOptions = type === "status" ? STATUS_OPTIONS : type === "assignee" ? ASSIGNEE_OPTIONS : options || []; + const selectOptions = type === "status" ? STATUS_OPTIONS : options || []; if (!isEditing) { return ( @@ -106,13 +119,40 @@ export const EditableTableCell = ({ )} title={value || placeholder} > - {value || placeholder} + + {/* Truncate long assignee names */} + {type === "assignee" && value && value.length > 20 ? `${value.slice(0, 17)}...` : value || placeholder} + ); } - // Render select for select types - if (type === "select" || type === "status" || type === "assignee") { + // Render ComboBox for assignee type + if (type === "assignee") { + return ( + { + setEditValue(newValue); + // Auto-save on change + setTimeout(() => { + onSave(newValue); + setIsEditing(false); + }, 0); + }} + placeholder="Select assignee..." + searchPlaceholder="Assign to..." + emptyMessage="Press Enter to add" + className={cn("w-full h-7 text-sm", className)} + allowCustomValue={true} + disabled={isSaving} + /> + ); + } + + // Render select for select/status types + if (type === "select" || type === "status") { return ( onAssigneeChange(value as Assignee)}> - e.stopPropagation()} + onKeyDown={(e) => { + // Stop propagation for all keys to prevent TaskCard from handling them + e.stopPropagation(); + }} + > + -
-
- {getAssigneeIcon(assignee, "md")} -
- {assignee} -
-
- - - {ASSIGNEE_OPTIONS.map((option) => { - const optionStyles = getAssigneeStyles(option); - - return ( - -
-
- {getAssigneeIcon(option, "md")} -
- {option} -
-
- ); - })} -
- + /> + ); }; diff --git a/archon-ui-main/src/features/projects/tasks/components/TaskEditModal.tsx b/archon-ui-main/src/features/projects/tasks/components/TaskEditModal.tsx index 2f839fe9..7a161c7f 100644 --- a/archon-ui-main/src/features/projects/tasks/components/TaskEditModal.tsx +++ b/archon-ui-main/src/features/projects/tasks/components/TaskEditModal.tsx @@ -1,6 +1,8 @@ import { memo, useCallback, useEffect, useState } from "react"; import { Button, + ComboBox, + type ComboBoxOption, Dialog, DialogContent, DialogFooter, @@ -18,7 +20,7 @@ import { TextArea, } from "../../../ui/primitives"; import { useTaskEditor } from "../hooks"; -import type { Assignee, Task, TaskPriority } from "../types"; +import { type Assignee, COMMON_ASSIGNEES, type Task, type TaskPriority } from "../types"; import { FeatureSelect } from "./FeatureSelect"; interface TaskEditModalProps { @@ -30,7 +32,13 @@ interface TaskEditModalProps { onOpenChange?: (open: boolean) => void; } -const ASSIGNEE_OPTIONS = ["User", "Archon", "AI IDE Agent"] as const; +// Convert common assignees to ComboBox options +const ASSIGNEE_OPTIONS: ComboBoxOption[] = COMMON_ASSIGNEES.map((name) => ({ + value: name, + label: name, + description: + name === "User" ? "Assign to human user" : name === "Archon" ? "Assign to Archon system" : "Assign to Coding Agent", +})); export const TaskEditModal = memo( ({ isModalOpen, editingTask, projectId, onClose, onSaved, onOpenChange }: TaskEditModalProps) => { @@ -153,23 +161,16 @@ export const TaskEditModal = memo( - + onValueChange={(value) => setLocalTask((prev) => (prev ? { ...prev, assignee: value } : null))} + placeholder="Select or type assignee..." + searchPlaceholder="Search or enter custom..." + emptyMessage="Type a custom assignee name" + className="w-full" + allowCustomValue={true} + /> diff --git a/archon-ui-main/src/features/projects/tasks/hooks/index.ts b/archon-ui-main/src/features/projects/tasks/hooks/index.ts index 9dc2cdc1..1fb13316 100644 --- a/archon-ui-main/src/features/projects/tasks/hooks/index.ts +++ b/archon-ui-main/src/features/projects/tasks/hooks/index.ts @@ -15,5 +15,6 @@ export { useCreateTask, useDeleteTask, useProjectTasks, + useTaskCounts, useUpdateTask, } from "./useTaskQueries"; diff --git a/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts b/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts index 51696870..ed1c6089 100644 --- a/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts +++ b/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts @@ -3,12 +3,13 @@ import { renderHook, waitFor } from "@testing-library/react"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Task } from "../../types"; -import { taskKeys, useCreateTask, useProjectTasks } from "../useTaskQueries"; +import { taskKeys, useCreateTask, useProjectTasks, useTaskCounts } from "../useTaskQueries"; // Mock the services vi.mock("../../services", () => ({ taskService: { getTasksByProject: vi.fn(), + getTaskCountsForAllProjects: vi.fn(), createTask: vi.fn(), updateTask: vi.fn(), deleteTask: vi.fn(), @@ -54,7 +55,11 @@ describe("useTaskQueries", () => { describe("taskKeys", () => { it("should generate correct query keys", () => { - expect(taskKeys.all("project-123")).toEqual(["projects", "project-123", "tasks"]); + expect(taskKeys.all).toEqual(["tasks"]); + expect(taskKeys.lists()).toEqual(["tasks", "list"]); + expect(taskKeys.detail("task-123")).toEqual(["tasks", "detail", "task-123"]); + expect(taskKeys.byProject("project-123")).toEqual(["projects", "project-123", "tasks"]); + expect(taskKeys.counts()).toEqual(["tasks", "counts"]); }); }); diff --git a/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts b/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts index e3e133d5..7ddb5192 100644 --- a/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts +++ b/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts @@ -1,21 +1,25 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns"; import { useSmartPolling } from "../../../ui/hooks"; import { useToast } from "../../../ui/hooks/useToast"; -import { projectKeys } from "../../hooks/useProjectQueries"; import { taskService } from "../services"; import type { CreateTaskRequest, Task, UpdateTaskRequest } from "../types"; -// Query keys factory for tasks +// Query keys factory for tasks - supports dual backend nature export const taskKeys = { - all: (projectId: string) => ["projects", projectId, "tasks"] as const, + all: ["tasks"] as const, + lists: () => [...taskKeys.all, "list"] as const, // For /api/tasks + detail: (id: string) => [...taskKeys.all, "detail", id] as const, // For /api/tasks/{id} + byProject: (projectId: string) => ["projects", projectId, "tasks"] as const, // For /api/projects/{id}/tasks + counts: () => [...taskKeys.all, "counts"] as const, // For /api/projects/task-counts }; // Fetch tasks for a specific project export function useProjectTasks(projectId: string | undefined, enabled = true) { - const { refetchInterval } = useSmartPolling(5000); // 5 second base interval for faster MCP updates + const { refetchInterval } = useSmartPolling(2000); // 2s active per guideline for real-time task updates return useQuery({ - queryKey: projectId ? taskKeys.all(projectId) : ["tasks-undefined"], + queryKey: projectId ? taskKeys.byProject(projectId) : DISABLED_QUERY_KEY, queryFn: async () => { if (!projectId) throw new Error("No project ID"); return taskService.getTasksByProject(projectId); @@ -23,7 +27,17 @@ export function useProjectTasks(projectId: string | undefined, enabled = true) { enabled: !!projectId && enabled, refetchInterval, // Smart interval based on page visibility/focus refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap) - staleTime: 10000, // Consider data stale after 10 seconds + staleTime: STALE_TIMES.frequent, + }); +} + +// Fetch task counts for all projects +export function useTaskCounts() { + return useQuery>>({ + queryKey: taskKeys.counts(), + queryFn: () => taskService.getTaskCountsForAllProjects(), + refetchInterval: false, // Don't poll, only refetch manually + staleTime: STALE_TIMES.rare, }); } @@ -36,10 +50,10 @@ export function useCreateTask() { mutationFn: (taskData: CreateTaskRequest) => taskService.createTask(taskData), onMutate: async (newTaskData) => { // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: taskKeys.all(newTaskData.project_id) }); + await queryClient.cancelQueries({ queryKey: taskKeys.byProject(newTaskData.project_id) }); // Snapshot the previous value - const previousTasks = queryClient.getQueryData(taskKeys.all(newTaskData.project_id)); + const previousTasks = queryClient.getQueryData(taskKeys.byProject(newTaskData.project_id)); // Create optimistic task with temporary ID const tempId = `temp-${Date.now()}`; @@ -48,14 +62,14 @@ export function useCreateTask() { ...newTaskData, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), - // Ensure all required fields have defaults + // Ensure all required fields have defaults (let backend handle assignee default) task_order: newTaskData.task_order ?? 100, status: newTaskData.status ?? "todo", - assignee: newTaskData.assignee ?? "User", + assignee: newTaskData.assignee ?? "User", // Keep for now as UI needs a value for optimistic update } as Task; // Optimistically add the new task - queryClient.setQueryData(taskKeys.all(newTaskData.project_id), (old: Task[] | undefined) => { + queryClient.setQueryData(taskKeys.byProject(newTaskData.project_id), (old: Task[] | undefined) => { if (!old) return [optimisticTask]; return [...old, optimisticTask]; }); @@ -67,13 +81,13 @@ export function useCreateTask() { console.error("Failed to create task:", error, { variables }); // Rollback on error if (context?.previousTasks) { - queryClient.setQueryData(taskKeys.all(variables.project_id), context.previousTasks); + queryClient.setQueryData(taskKeys.byProject(variables.project_id), context.previousTasks); } showToast(`Failed to create task: ${errorMessage}`, "error"); }, onSuccess: (data, variables, context) => { // Replace optimistic task with real one from server - queryClient.setQueryData(taskKeys.all(variables.project_id), (old: Task[] | undefined) => { + queryClient.setQueryData(taskKeys.byProject(variables.project_id), (old: Task[] | undefined) => { if (!old) return [data]; // Replace only the specific temp task with real one return old @@ -84,12 +98,12 @@ export function useCreateTask() { index === self.findIndex((t) => t.id === task.id), ); }); - queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + queryClient.invalidateQueries({ queryKey: taskKeys.counts() }); showToast("Task created successfully", "success"); }, onSettled: (_data, _error, variables) => { // Always refetch to ensure consistency after operation completes - queryClient.invalidateQueries({ queryKey: taskKeys.all(variables.project_id) }); + queryClient.invalidateQueries({ queryKey: taskKeys.byProject(variables.project_id) }); }, }); } @@ -104,13 +118,13 @@ export function useUpdateTask(projectId: string) { taskService.updateTask(taskId, updates), onMutate: async ({ taskId, updates }) => { // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) }); + await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) }); // Snapshot the previous value - const previousTasks = queryClient.getQueryData(taskKeys.all(projectId)); + const previousTasks = queryClient.getQueryData(taskKeys.byProject(projectId)); // Optimistically update - queryClient.setQueryData(taskKeys.all(projectId), (old) => { + queryClient.setQueryData(taskKeys.byProject(projectId), (old) => { if (!old) return old; return old.map((task) => (task.id === taskId ? { ...task, ...updates } : task)); }); @@ -122,21 +136,24 @@ export function useUpdateTask(projectId: string) { console.error("Failed to update task:", error, { variables }); // Rollback on error if (context?.previousTasks) { - queryClient.setQueryData(taskKeys.all(projectId), context.previousTasks); + queryClient.setQueryData(taskKeys.byProject(projectId), context.previousTasks); } showToast(`Failed to update task: ${errorMessage}`, "error"); // Refetch on error to ensure consistency - queryClient.invalidateQueries({ queryKey: taskKeys.all(projectId) }); - queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + queryClient.invalidateQueries({ queryKey: taskKeys.byProject(projectId) }); + // Only invalidate counts if status was changed + if (variables.updates?.status) { + queryClient.invalidateQueries({ queryKey: taskKeys.counts() }); + } }, onSuccess: (data, { updates }) => { // Merge server response to keep timestamps and computed fields in sync - queryClient.setQueryData(taskKeys.all(projectId), (old) => + queryClient.setQueryData(taskKeys.byProject(projectId), (old) => old ? old.map((t) => (t.id === data.id ? data : t)) : old, ); // Only invalidate counts if status changed (which affects counts) if (updates.status) { - queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + queryClient.invalidateQueries({ queryKey: taskKeys.counts() }); // Show toast for significant status changes showToast(`Task moved to ${updates.status}`, "success"); } @@ -153,13 +170,13 @@ export function useDeleteTask(projectId: string) { mutationFn: (taskId: string) => taskService.deleteTask(taskId), onMutate: async (taskId) => { // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) }); + await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) }); // Snapshot the previous value - const previousTasks = queryClient.getQueryData(taskKeys.all(projectId)); + const previousTasks = queryClient.getQueryData(taskKeys.byProject(projectId)); // Optimistically remove the task - queryClient.setQueryData(taskKeys.all(projectId), (old) => { + queryClient.setQueryData(taskKeys.byProject(projectId), (old) => { if (!old) return old; return old.filter((task) => task.id !== taskId); }); @@ -171,7 +188,7 @@ export function useDeleteTask(projectId: string) { console.error("Failed to delete task:", error, { taskId }); // Rollback on error if (context?.previousTasks) { - queryClient.setQueryData(taskKeys.all(projectId), context.previousTasks); + queryClient.setQueryData(taskKeys.byProject(projectId), context.previousTasks); } showToast(`Failed to delete task: ${errorMessage}`, "error"); }, @@ -180,7 +197,9 @@ export function useDeleteTask(projectId: string) { }, onSettled: () => { // Always refetch counts after deletion - queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + queryClient.invalidateQueries({ queryKey: taskKeys.counts() }); + // Also refetch the project's task list to reconcile server-side ordering + queryClient.invalidateQueries({ queryKey: taskKeys.byProject(projectId) }); }, }); } diff --git a/archon-ui-main/src/features/projects/tasks/schemas/index.ts b/archon-ui-main/src/features/projects/tasks/schemas/index.ts index aee7a419..839b4271 100644 --- a/archon-ui-main/src/features/projects/tasks/schemas/index.ts +++ b/archon-ui-main/src/features/projects/tasks/schemas/index.ts @@ -4,8 +4,11 @@ import { z } from "zod"; export const DatabaseTaskStatusSchema = z.enum(["todo", "doing", "review", "done"]); export const TaskPrioritySchema = z.enum(["low", "medium", "high", "critical"]); -// Assignee schema - simplified to predefined options -export const AssigneeSchema = z.enum(["User", "Archon", "AI IDE Agent"]); +// Assignee schema - flexible string for any agent name +export const AssigneeSchema = z + .string() + .min(1, "Assignee cannot be empty") + .max(100, "Assignee name must be less than 100 characters"); // Task schemas export const CreateTaskSchema = z.object({ diff --git a/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts b/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts index 13a3f3df..d86cc94d 100644 --- a/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts +++ b/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts @@ -280,7 +280,7 @@ describe("taskService", () => { title: "Full Task", description: "This is a detailed description that should persist", status: "todo", - assignee: "AI IDE Agent", + assignee: "Coding Agent", task_order: 100, priority: "critical", feature: "authentication", diff --git a/archon-ui-main/src/features/projects/tasks/types/index.ts b/archon-ui-main/src/features/projects/tasks/types/index.ts index 34311573..aa6570c5 100644 --- a/archon-ui-main/src/features/projects/tasks/types/index.ts +++ b/archon-ui-main/src/features/projects/tasks/types/index.ts @@ -9,6 +9,7 @@ export type { UseTaskActionsReturn, UseTaskEditorReturn } from "./hooks"; // Core task types (vertical slice architecture) export type { Assignee, + CommonAssignee, CreateTaskRequest, DatabaseTaskStatus, Task, @@ -18,3 +19,6 @@ export type { TaskSource, UpdateTaskRequest, } from "./task"; + +// Export constants +export { COMMON_ASSIGNEES } from "./task"; diff --git a/archon-ui-main/src/features/projects/tasks/types/task.ts b/archon-ui-main/src/features/projects/tasks/types/task.ts index 5e1674dd..a38947eb 100644 --- a/archon-ui-main/src/features/projects/tasks/types/task.ts +++ b/archon-ui-main/src/features/projects/tasks/types/task.ts @@ -11,8 +11,12 @@ export type { TaskPriority }; // Database status enum - using database values directly export type DatabaseTaskStatus = "todo" | "doing" | "review" | "done"; -// Assignee type - simplified to predefined options -export type Assignee = "User" | "Archon" | "AI IDE Agent"; +// Assignee type - flexible string to support any agent name +export type Assignee = string; + +// Common assignee options for UI suggestions +export const COMMON_ASSIGNEES = ["User", "Archon", "Coding Agent"] as const; +export type CommonAssignee = (typeof COMMON_ASSIGNEES)[number]; // Task counts for project overview export interface TaskCounts { @@ -46,7 +50,7 @@ export interface Task { title: string; description: string; status: DatabaseTaskStatus; - assignee: Assignee; + assignee: Assignee; // Can be any string - agent names, "User", etc. task_order: number; feature?: string; sources?: TaskSource[]; @@ -72,7 +76,7 @@ export interface CreateTaskRequest { title: string; description: string; status?: DatabaseTaskStatus; - assignee?: Assignee; + assignee?: Assignee; // Optional assignee string task_order?: number; feature?: string; featureColor?: string; @@ -85,7 +89,7 @@ export interface UpdateTaskRequest { title?: string; description?: string; status?: DatabaseTaskStatus; - assignee?: Assignee; + assignee?: Assignee; // Optional assignee string task_order?: number; feature?: string; featureColor?: string; diff --git a/archon-ui-main/src/features/projects/tasks/utils/task-styles.tsx b/archon-ui-main/src/features/projects/tasks/utils/task-styles.tsx index fd519815..7d9082ac 100644 --- a/archon-ui-main/src/features/projects/tasks/utils/task-styles.tsx +++ b/archon-ui-main/src/features/projects/tasks/utils/task-styles.tsx @@ -11,7 +11,7 @@ export const getAssigneeIcon = (assigneeName: Assignee) => { switch (assigneeName) { case "User": return ; - case "AI IDE Agent": + case "Coding Agent": return ; case "Archon": return Archon; @@ -25,7 +25,7 @@ export const getAssigneeGlow = (assigneeName: Assignee) => { switch (assigneeName) { case "User": return "shadow-[0_0_10px_rgba(59,130,246,0.4)]"; - case "AI IDE Agent": + case "Coding Agent": return "shadow-[0_0_10px_rgba(168,85,247,0.4)]"; case "Archon": return "shadow-[0_0_10px_rgba(34,211,238,0.4)]"; diff --git a/archon-ui-main/src/features/projects/views/ProjectsView.tsx b/archon-ui-main/src/features/projects/views/ProjectsView.tsx index 60f18eeb..ceac8176 100644 --- a/archon-ui-main/src/features/projects/views/ProjectsView.tsx +++ b/archon-ui-main/src/features/projects/views/ProjectsView.tsx @@ -9,13 +9,8 @@ import { NewProjectModal } from "../components/NewProjectModal"; import { ProjectHeader } from "../components/ProjectHeader"; import { ProjectList } from "../components/ProjectList"; import { DocsTab } from "../documents/DocsTab"; -import { - projectKeys, - useDeleteProject, - useProjects, - useTaskCounts, - useUpdateProject, -} from "../hooks/useProjectQueries"; +import { projectKeys, useDeleteProject, useProjects, useUpdateProject } from "../hooks/useProjectQueries"; +import { useTaskCounts } from "../tasks/hooks"; import { TasksTab } from "../tasks/TasksTab"; import type { Project } from "../types"; diff --git a/archon-ui-main/src/features/shared/queryPatterns.ts b/archon-ui-main/src/features/shared/queryPatterns.ts new file mode 100644 index 00000000..efa10096 --- /dev/null +++ b/archon-ui-main/src/features/shared/queryPatterns.ts @@ -0,0 +1,24 @@ +/** + * Shared Query Patterns + * + * Consistent patterns for TanStack Query across all features + * + * USAGE GUIDELINES: + * - Always use DISABLED_QUERY_KEY for disabled queries + * - Always use STALE_TIMES constants for staleTime configuration + * - Never hardcode stale times directly in hooks + */ + +// Consistent disabled query key - use when query should not execute +export const DISABLED_QUERY_KEY = ["disabled"] as const; + +// Consistent stale times by update frequency +// Use these to ensure predictable caching behavior across the app +export const STALE_TIMES = { + instant: 0, // Always fresh - for real-time data like active progress + realtime: 3_000, // 3 seconds - for near real-time updates + frequent: 5_000, // 5 seconds - for frequently changing data + normal: 30_000, // 30 seconds - standard cache time for most data + rare: 300_000, // 5 minutes - for rarely changing configuration + static: Infinity, // Never stale - for static data like settings +} as const; diff --git a/archon-ui-main/src/features/ui/hooks/useSmartPolling.ts b/archon-ui-main/src/features/ui/hooks/useSmartPolling.ts index 0a286e93..521074d4 100644 --- a/archon-ui-main/src/features/ui/hooks/useSmartPolling.ts +++ b/archon-ui-main/src/features/ui/hooks/useSmartPolling.ts @@ -49,8 +49,8 @@ export function useSmartPolling(baseInterval: number = 10000) { } if (!hasFocus) { - // Page is visible but not focused - poll less frequently (1 minute) - return 60000; // 60 seconds for background polling + // Page is visible but not focused - poll less frequently + return 5000; // 5 seconds for background polling (aligned with polling guidelines) } // Page is active - use normal interval diff --git a/archon-ui-main/src/features/ui/primitives/combobox.tsx b/archon-ui-main/src/features/ui/primitives/combobox.tsx index 807f6b6f..f9ca0204 100644 --- a/archon-ui-main/src/features/ui/primitives/combobox.tsx +++ b/archon-ui-main/src/features/ui/primitives/combobox.tsx @@ -1,12 +1,13 @@ /** * ComboBox Primitive * - * A searchable dropdown component built with Radix UI Popover and Command + * A searchable dropdown component built with Radix UI Popover * Provides autocomplete functionality with keyboard navigation + * Follows WAI-ARIA combobox pattern for accessibility */ import * as Popover from "@radix-ui/react-popover"; -import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { Check, Loader2 } from "lucide-react"; import * as React from "react"; import { Button } from "./button"; import { cn } from "./styles"; @@ -25,10 +26,26 @@ interface ComboBoxProps { searchPlaceholder?: string; emptyMessage?: string; className?: string; + disabled?: boolean; isLoading?: boolean; allowCustomValue?: boolean; + "aria-label"?: string; + "aria-labelledby"?: string; + "aria-describedby"?: string; } +/** + * ComboBox component with search and custom value support + * + * @example + * + */ export const ComboBox = React.forwardRef( ( { @@ -39,86 +56,168 @@ export const ComboBox = React.forwardRef( searchPlaceholder = "Search...", emptyMessage = "No results found.", className, + disabled = false, isLoading = false, allowCustomValue = false, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, }, ref, ) => { + // State management const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + + // Refs for DOM elements const inputRef = React.useRef(null); + const optionsRef = React.useRef(null); + const listboxId = React.useId(); - // Filter options based on search + // Memoized filtered options const filteredOptions = React.useMemo(() => { - if (!search) return options; + if (!search.trim()) return options; - const searchLower = search.toLowerCase(); + const searchLower = search.toLowerCase().trim(); return options.filter( (option) => - option.label.toLowerCase().includes(searchLower) || - option.value.toLowerCase().includes(searchLower) || - option.description?.toLowerCase().includes(searchLower), + option.label.toLowerCase().includes(searchLower) || option.value.toLowerCase().includes(searchLower), ); }, [options, search]); - // Find current option label - const selectedOption = options.find((opt) => opt.value === value); + // Derived state + const selectedOption = React.useMemo(() => options.find((opt) => opt.value === value), [options, value]); const displayValue = selectedOption?.label || value || ""; + const hasCustomOption = + allowCustomValue && + search.trim() && + !filteredOptions.some((opt) => opt.label.toLowerCase() === search.toLowerCase()); - // Handle selection - const handleSelect = (optionValue: string) => { - onValueChange(optionValue); - setOpen(false); - setSearch(""); - }; - - // Handle custom value input - const handleCustomValue = () => { - if (allowCustomValue && search && !filteredOptions.some((opt) => opt.label === search)) { - onValueChange(search); + // Event handlers + const handleSelect = React.useCallback( + (optionValue: string) => { + onValueChange(optionValue); setOpen(false); setSearch(""); - } - }; + setHighlightedIndex(0); + }, + [onValueChange], + ); - // Focus input when opening + const handleCustomValue = React.useCallback(() => { + if (hasCustomOption) { + handleSelect(search.trim()); + } + }, [hasCustomOption, search, handleSelect]); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + if (filteredOptions.length > 0 && highlightedIndex < filteredOptions.length) { + handleSelect(filteredOptions[highlightedIndex].value); + } else if (hasCustomOption) { + handleCustomValue(); + } + break; + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => { + const maxIndex = hasCustomOption ? filteredOptions.length : filteredOptions.length - 1; + return Math.min(prev + 1, maxIndex); + }); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => Math.max(prev - 1, 0)); + break; + case "Escape": + e.preventDefault(); + setOpen(false); + break; + case "Tab": + // Allow natural tab behavior to close dropdown + setOpen(false); + break; + } + }, + [filteredOptions, highlightedIndex, hasCustomOption, handleSelect, handleCustomValue], + ); + + // Focus management React.useEffect(() => { - if (open && inputRef.current) { - setTimeout(() => inputRef.current?.focus(), 0); + if (open) { + setSearch(""); + setHighlightedIndex(0); + // Use RAF for more reliable focus + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); } }, [open]); + // Scroll highlighted option into view + React.useEffect(() => { + if (open && optionsRef.current) { + const highlightedElement = optionsRef.current.querySelector('[data-highlighted="true"]'); + highlightedElement?.scrollIntoView({ block: "nearest" }); + } + }, [highlightedIndex, open]); + return ( + ( "rounded-lg shadow-xl", "shadow-cyan-500/10 dark:shadow-cyan-400/10", "z-50", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", )} align="start" sideOffset={4} + onOpenAutoFocus={(e) => e.preventDefault()} > - {/* Search Input */} -
+
+ {/* Search Input */} setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && allowCustomValue && search) { - e.preventDefault(); - handleCustomValue(); - } + onChange={(e) => { + setSearch(e.target.value); + setHighlightedIndex(0); }} + onKeyDown={(e) => { + e.stopPropagation(); // Stop propagation first + handleKeyDown(e); + }} + onClick={(e) => e.stopPropagation()} placeholder={searchPlaceholder} className={cn( - "w-full px-3 py-1.5 text-sm", + "w-full px-2 py-1 text-xs", "bg-white/50 dark:bg-black/50", "border border-gray-200 dark:border-gray-700", - "rounded-md", + "rounded", "text-gray-900 dark:text-white", "placeholder-gray-500 dark:placeholder-gray-400", - "focus:outline-none focus:border-cyan-400", - "focus:shadow-[0_0_10px_rgba(34,211,238,0.2)]", + "focus:outline-none focus:ring-1 focus:ring-cyan-400", "transition-all duration-200", )} /> -
- {/* Options List */} -
- {isLoading ? ( -
- - Loading options... -
- ) : filteredOptions.length === 0 ? ( -
- {emptyMessage} - {allowCustomValue && search && ( - - )} -
- ) : ( - filteredOptions.map((option) => ( - - )) - )} + {emptyMessage} +
+ ) : ( + <> + {filteredOptions.map((option, index) => { + const isSelected = value === option.value; + const isHighlighted = highlightedIndex === index; + + return ( + + ); + })} + + {hasCustomOption && ( + + )} + + )} +
diff --git a/python/src/mcp_server/features/tasks/task_tools.py b/python/src/mcp_server/features/tasks/task_tools.py index 00862e8b..d5e865ee 100644 --- a/python/src/mcp_server/features/tasks/task_tools.py +++ b/python/src/mcp_server/features/tasks/task_tools.py @@ -130,6 +130,13 @@ def register_task_tools(mcp: FastMCP): params["include_closed"] = include_closed if project_id: params["project_id"] = project_id + elif filter_by == "assignee" and filter_value: + # Use generic tasks endpoint for assignee filtering + url = urljoin(api_url, "/api/tasks") + params["assignee"] = filter_value + params["include_closed"] = include_closed + if project_id: + params["project_id"] = project_id elif project_id: # Direct project_id parameter provided url = urljoin(api_url, "/api/tasks") @@ -212,13 +219,17 @@ def register_task_tools(mcp: FastMCP): title: Task title text description: Detailed task description status: "todo" | "doing" | "review" | "done" - assignee: "User" | "Archon" | "AI IDE Agent" + assignee: String name of the assignee. Can be any agent name, + "User" for human assignment, or custom agent identifiers + created by your system (e.g., "ResearchAgent-1", "CodeReviewer"). + Common values: "User", "Archon", "Coding Agent" + Default: "User" task_order: Priority 0-100 (higher = more priority) feature: Feature label for grouping - + Examples: - manage_task("create", project_id="p-1", title="Fix auth bug") - manage_task("update", task_id="t-1", status="doing") + manage_task("create", project_id="p-1", title="Fix auth bug", assignee="CodeAnalyzer-v2") + manage_task("update", task_id="t-1", status="doing", assignee="User") manage_task("delete", task_id="t-1") Returns: {success: bool, task?: object, message: string} diff --git a/python/src/server/services/knowledge/knowledge_summary_service.py b/python/src/server/services/knowledge/knowledge_summary_service.py index cee03305..91c0107e 100644 --- a/python/src/server/services/knowledge/knowledge_summary_service.py +++ b/python/src/server/services/knowledge/knowledge_summary_service.py @@ -142,10 +142,9 @@ class KnowledgeSummaryService: "code_examples_count": code_counts.get(source_id, 0), "knowledge_type": knowledge_type, "source_type": source_type, - "tags": metadata.get("tags", []), "created_at": source.get("created_at"), "updated_at": source.get("updated_at"), - "metadata": metadata, # Include full metadata for debugging + "metadata": metadata, # Include full metadata (contains tags) } summaries.append(summary)