From af3485c980b4bd51241ce4d905d5aabff4177291 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Wed, 3 Sep 2025 09:46:48 +0300 Subject: [PATCH] POC: TanStack Query implementation with conditional devtools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace manual useState polling with TanStack Query for projects/tasks - Add comprehensive query key factories for cache management - Implement optimistic updates with automatic rollback - Create progress polling hooks with smart completion detection - Add VITE_SHOW_DEVTOOLS environment variable for conditional devtools - Remove legacy hooks: useDatabaseMutation, usePolling, useProjectMutation - Update components to use mutation hooks directly (reduce prop drilling) - Enhanced QueryClient with optimized polling and caching settings 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 6 + archon-ui-main/package-lock.json | 55 ++ archon-ui-main/package.json | 2 + archon-ui-main/src/App.tsx | 43 +- .../knowledge-base/CrawlingProgressCard.tsx | 2 +- .../src/components/project-tasks/DocsTab.tsx | 2 +- .../project-tasks/DraggableTaskCard.tsx | 6 - .../project-tasks/EditTaskModal.tsx | 89 +- .../project-tasks/TaskTableView.tsx | 146 ++- .../src/components/project-tasks/TasksTab.tsx | 548 ++++-------- archon-ui-main/src/hooks/useCrawlQueries.ts | 433 +++++++++ .../src/hooks/useDatabaseMutation.ts | 194 ---- archon-ui-main/src/hooks/useMCPQueries.ts | 77 ++ archon-ui-main/src/hooks/usePolling.ts | 338 ------- .../src/hooks/useProjectMutation.ts | 125 --- archon-ui-main/src/hooks/useProjectQueries.ts | 254 ++++++ archon-ui-main/src/pages/ProjectPage.tsx | 844 ++++++------------ archon-ui-main/src/services/projectService.ts | 8 +- docker-compose.yml | 1 + report.md | 297 ++++++ 20 files changed, 1723 insertions(+), 1747 deletions(-) create mode 100644 archon-ui-main/src/hooks/useCrawlQueries.ts delete mode 100644 archon-ui-main/src/hooks/useDatabaseMutation.ts create mode 100644 archon-ui-main/src/hooks/useMCPQueries.ts delete mode 100644 archon-ui-main/src/hooks/usePolling.ts delete mode 100644 archon-ui-main/src/hooks/useProjectMutation.ts create mode 100644 archon-ui-main/src/hooks/useProjectQueries.ts create mode 100644 report.md diff --git a/.env.example b/.env.example index dc00d2e6..4077e9cd 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,12 @@ ARCHON_DOCS_PORT=3838 # If not set, defaults to localhost, 127.0.0.1, ::1, and the HOST value above VITE_ALLOWED_HOSTS= +# Development Tools +# VITE_SHOW_DEVTOOLS: Show TanStack Query DevTools (for developers only) +# Set to "true" to enable the DevTools panel in bottom right corner +# Defaults to "false" for end users +VITE_SHOW_DEVTOOLS=false + # When enabled, PROD mode will proxy ARCHON_SERVER_PORT through ARCHON_UI_PORT. This exposes both the # Archon UI and API through a single port. This is useful when deploying Archon behind a reverse # proxy where you want to expose the frontend on a single external domain. diff --git a/archon-ui-main/package-lock.json b/archon-ui-main/package-lock.json index c5a0773e..f6570a5e 100644 --- a/archon-ui-main/package-lock.json +++ b/archon-ui-main/package-lock.json @@ -12,6 +12,8 @@ "@milkdown/kit": "^7.5.0", "@milkdown/plugin-history": "^7.5.0", "@milkdown/preset-commonmark": "^7.5.0", + "@tanstack/react-query": "^5.85.8", + "@tanstack/react-query-devtools": "^5.85.8", "@xyflow/react": "^12.3.0", "clsx": "latest", "date-fns": "^4.1.0", @@ -2571,6 +2573,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.85.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.7.tgz", + "integrity": "sha512-FLT3EtuTbXBmOrDku4bI80Eivmjn/o/Zc1lVEd/6yzR8UAUSnDwYiwghCZvLqHyGSN5mO35ux1yPGMFYBFRSwA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.84.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz", + "integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.85.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.8.tgz", + "integrity": "sha512-r3rW55STAO03EJg5mrCVIJvaEK3oeHme5u7QovuRFIKRbEgTzTv2DPdenX46X+x56LsU3ree1N4rzI/+gJ7KEA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.85.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.8.tgz", + "integrity": "sha512-83SXqRpmVlRMpaj32veez/8ohjY7O4VQIYDqW91b4i9AQjiYgE24FbBfR/SOL8b5MfKhHMZkD+BQSpCh9jY06w==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.84.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.85.8", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", diff --git a/archon-ui-main/package.json b/archon-ui-main/package.json index 1f5a91c8..1e9ebed6 100644 --- a/archon-ui-main/package.json +++ b/archon-ui-main/package.json @@ -22,6 +22,8 @@ "@milkdown/kit": "^7.5.0", "@milkdown/plugin-history": "^7.5.0", "@milkdown/preset-commonmark": "^7.5.0", + "@tanstack/react-query": "^5.85.8", + "@tanstack/react-query-devtools": "^5.85.8", "@xyflow/react": "^12.3.0", "clsx": "latest", "date-fns": "^4.1.0", diff --git a/archon-ui-main/src/App.tsx b/archon-ui-main/src/App.tsx index 427347cb..a47a0461 100644 --- a/archon-ui-main/src/App.tsx +++ b/archon-ui-main/src/App.tsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { KnowledgeBasePage } from './pages/KnowledgeBasePage'; import { SettingsPage } from './pages/SettingsPage'; import { MCPPage } from './pages/MCPPage'; @@ -15,6 +17,28 @@ import { MigrationBanner } from './components/ui/MigrationBanner'; import { serverHealthService } from './services/serverHealthService'; import { useMigrationStatus } from './hooks/useMigrationStatus'; +// Create a client with optimized settings for our polling use case +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Keep data fresh for 2 seconds by default + staleTime: 2000, + // Cache data for 5 minutes + gcTime: 5 * 60 * 1000, + // Retry failed requests 3 times + retry: 3, + // Refetch on window focus + refetchOnWindowFocus: true, + // Don't refetch on reconnect by default (we handle this manually) + refetchOnReconnect: false, + }, + mutations: { + // Retry mutations once on failure + retry: 1, + }, + }, +}); + const AppRoutes = () => { const { projectsEnabled } = useSettings(); @@ -105,12 +129,17 @@ const AppContent = () => { export function App() { return ( - - - - - - - + + + + + + + + + {import.meta.env.VITE_SHOW_DEVTOOLS === 'true' && ( + + )} + ); } \ No newline at end of file diff --git a/archon-ui-main/src/components/knowledge-base/CrawlingProgressCard.tsx b/archon-ui-main/src/components/knowledge-base/CrawlingProgressCard.tsx index 0f391581..f5eeb5aa 100644 --- a/archon-ui-main/src/components/knowledge-base/CrawlingProgressCard.tsx +++ b/archon-ui-main/src/components/knowledge-base/CrawlingProgressCard.tsx @@ -26,7 +26,7 @@ import { Card } from '../ui/Card'; import { Button } from '../ui/Button'; import { Badge } from '../ui/Badge'; import { CrawlProgressData } from '../../types/crawl'; -import { useCrawlProgressPolling } from '../../hooks/usePolling'; +import { useCrawlProgressPolling } from '../../hooks/useCrawlQueries'; import { useTerminalScroll } from '../../hooks/useTerminalScroll'; interface CrawlingProgressCardProps { diff --git a/archon-ui-main/src/components/project-tasks/DocsTab.tsx b/archon-ui-main/src/components/project-tasks/DocsTab.tsx index e2e08624..145612c5 100644 --- a/archon-ui-main/src/components/project-tasks/DocsTab.tsx +++ b/archon-ui-main/src/components/project-tasks/DocsTab.tsx @@ -8,7 +8,7 @@ import { Input } from '../ui/Input'; import { Card } from '../ui/Card'; import { Badge } from '../ui/Badge'; import { Select } from '../ui/Select'; -import { useCrawlProgressPolling } from '../../hooks/usePolling'; +import { useCrawlProgressPolling } from '../../hooks/useCrawlQueries'; import { MilkdownEditor } from './MilkdownEditor'; import { VersionHistoryModal } from './VersionHistoryModal'; import { PRPViewer } from '../prp'; diff --git a/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx b/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx index 62435e3b..211c313f 100644 --- a/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx +++ b/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx @@ -145,12 +145,6 @@ export const DraggableTaskCard = ({ {task.feature} - - {/* Task order display */} -
- {task.task_order} -
- {/* Action buttons group */}
-
@@ -216,28 +211,6 @@ export const EditTaskModal = memo(({ ); -}, (prevProps, nextProps) => { - // Custom comparison function to prevent unnecessary re-renders - // Only re-render if these specific props change - const isEqual = ( - prevProps.isModalOpen === nextProps.isModalOpen && - prevProps.editingTask?.id === nextProps.editingTask?.id && - prevProps.editingTask?.title === nextProps.editingTask?.title && - prevProps.editingTask?.description === nextProps.editingTask?.description && - prevProps.editingTask?.status === nextProps.editingTask?.status && - prevProps.editingTask?.assignee?.name === nextProps.editingTask?.assignee?.name && - prevProps.editingTask?.feature === nextProps.editingTask?.feature && - prevProps.editingTask?.task_order === nextProps.editingTask?.task_order && - prevProps.isSavingTask === nextProps.isSavingTask && - prevProps.isLoadingFeatures === nextProps.isLoadingFeatures && - prevProps.projectFeatures === nextProps.projectFeatures // Reference equality check - ); - - if (!isEqual) { - console.log('[EditTaskModal] Props changed, re-rendering'); - } - - return isEqual; }); EditTaskModal.displayName = 'EditTaskModal'; \ No newline at end of file diff --git a/archon-ui-main/src/components/project-tasks/TaskTableView.tsx b/archon-ui-main/src/components/project-tasks/TaskTableView.tsx index e830e0ea..f4d8851d 100644 --- a/archon-ui-main/src/components/project-tasks/TaskTableView.tsx +++ b/archon-ui-main/src/components/project-tasks/TaskTableView.tsx @@ -7,19 +7,11 @@ import { projectService } from '../../services/projectService'; import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils'; import { DraggableTaskCard } from './DraggableTaskCard'; -export interface Task { - id: string; - title: string; - description: string; - status: 'todo' | 'doing' | 'review' | 'done'; - assignee: { - name: 'User' | 'Archon' | 'AI IDE Agent'; - avatar: string; - }; - feature: string; - featureColor: string; - task_order: number; -} +// Import Task from types instead of redefining +import type { Task, Assignee } from '../../types/project'; + +// Re-export Task for components that import from here +export type { Task } from '../../types/project'; interface TaskTableViewProps { tasks: Task[]; @@ -78,7 +70,7 @@ const reorderTasks = (tasks: Task[], fromIndex: number, toIndex: number): Task[] interface EditableCellProps { value: string; onSave: (value: string) => void; - type?: 'text' | 'textarea' | 'select'; + type?: 'text' | 'textarea' | 'select' | 'assignee'; options?: string[]; placeholder?: string; isEditing: boolean; @@ -96,14 +88,19 @@ const EditableCell = ({ onEdit, onCancel }: EditableCellProps) => { - const [editValue, setEditValue] = useState(value); + const [editValue, setEditValue] = useState(value || ''); + + // Update editValue when value prop changes + React.useEffect(() => { + setEditValue(value || ''); + }, [value]); const handleSave = () => { onSave(editValue); }; const handleCancel = () => { - setEditValue(value); + setEditValue(value || ''); onCancel(); }; @@ -124,6 +121,22 @@ const EditableCell = ({ }; if (!isEditing) { + // Special display for assignee type + if (type === 'assignee') { + return ( +
+
+ {getAssigneeIcon(value as any)} + {value} +
+
+ ); + } + return (
- {type === 'select' ? ( + {(type === 'select' || type === 'assignee') ? ( { - handleUpdateField('assignee', e.target.value); - setEditingField(null); - }} - className="bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-cyan-500" - autoFocus - > - - - - -
- )} - + handleUpdateField('assignee', value)} + type="assignee" + options={['User', 'Archon', 'AI IDE Agent']} + isEditing={editingField === 'assignee'} + onEdit={() => setEditingField('assignee')} + onCancel={() => setEditingField(null)} + />
@@ -422,7 +412,7 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => { title: '', description: '', status: statusFilter === 'all' ? 'todo' : statusFilter, - assignee: { name: 'AI IDE Agent', avatar: '' }, + assignee: 'AI IDE Agent' as Assignee, feature: '', featureColor: '#3b82f6', task_order: 1 @@ -434,7 +424,7 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => { // Calculate the next order number for the target status const targetStatus = newTask.status; const tasksInStatus = tasks.filter(t => t.status === targetStatus); - const nextOrder = tasksInStatus.length > 0 ? Math.max(...tasksInStatus.map(t => t.task_order)) + 1 : 1; + const nextOrder = tasksInStatus.length > 0 ? Math.max(...tasksInStatus.map(t => t.task_order)) + 100 : 100; try { await onTaskCreate({ @@ -442,11 +432,12 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => { task_order: nextOrder }); - // Reset only the title to allow quick adding + // Reset the form for quick adding setNewTask(prev => ({ ...prev, title: '', - description: '' + description: '', + feature: '' })); } catch (error) { console.error('Failed to create task:', error); @@ -471,29 +462,27 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => { <> {/* Toned down neon blue line separator */} - +
-
+
+
+ setNewTask(prev => ({ ...prev, title: e.target.value }))} + onKeyPress={handleKeyPress} + placeholder="Type task title and press Enter..." + className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)] transition-all duration-200" + autoFocus + />
- - setNewTask(prev => ({ ...prev, title: e.target.value }))} - onKeyPress={handleKeyPress} - placeholder="Type task title and press Enter..." - className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)] transition-all duration-200" - autoFocus - /> - setNewTask(prev => ({ ...prev, - assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' } + assignee: e.target.value as Assignee }))} className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]" > @@ -551,7 +540,7 @@ export const TaskTableView = ({ onTaskCreate, onTaskUpdate }: TaskTableViewProps) => { - const [statusFilter, setStatusFilter] = useState('todo'); + const [statusFilter, setStatusFilter] = useState('all'); // State for delete confirmation modal const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -775,7 +764,6 @@ export const TaskTableView = ({ > - @@ -784,14 +772,6 @@ export const TaskTableView = ({ -
-
- Order - -
- {/* Header divider with glow matching board view */} -
-
Task diff --git a/archon-ui-main/src/components/project-tasks/TasksTab.tsx b/archon-ui-main/src/components/project-tasks/TasksTab.tsx index dff0d58f..d86a2a6c 100644 --- a/archon-ui-main/src/components/project-tasks/TasksTab.tsx +++ b/archon-ui-main/src/components/project-tasks/TasksTab.tsx @@ -1,83 +1,42 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { Table, LayoutGrid, Plus } from 'lucide-react'; +import React, { useState, useMemo, useCallback } from 'react'; +import { Plus, Table, LayoutGrid } from 'lucide-react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; -import { Toggle } from '../ui/Toggle'; -import { projectService } from '../../services/projectService'; +import { debounce } from 'lodash'; import { useToast } from '../../contexts/ToastContext'; -import { debounce } from '../../utils/debounce'; -import { calculateReorderPosition, getDefaultTaskOrder } from '../../utils/taskOrdering'; +import { + useProjectTasks, + useProjectFeatures, + useCreateTask, + useUpdateTask, + useDeleteTask +} from '../../hooks/useProjectQueries'; import type { CreateTaskRequest, UpdateTaskRequest } from '../../types/project'; import { TaskTableView, Task } from './TaskTableView'; import { TaskBoardView } from './TaskBoardView'; import { EditTaskModal } from './EditTaskModal'; -// Type for optimistic task updates with operation tracking -type OptimisticTask = Task & { _optimisticOperationId: string }; - - - -export const TasksTab = ({ - initialTasks, - onTasksChange, - projectId -}: { - initialTasks: Task[]; - onTasksChange: (tasks: Task[]) => void; - projectId: string; -}) => { +export const TasksTab = ({ projectId }: { projectId: string }) => { const { showToast } = useToast(); const [viewMode, setViewMode] = useState<'table' | 'board'>('board'); - const [tasks, setTasks] = useState([]); const [editingTask, setEditingTask] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [projectFeatures, setProjectFeatures] = useState([]); - const [isLoadingFeatures, setIsLoadingFeatures] = useState(false); const [isSavingTask, setIsSavingTask] = useState(false); - const [optimisticTaskUpdates, setOptimisticTaskUpdates] = useState>(new Map()); - // Initialize tasks, but preserve optimistic updates - useEffect(() => { - if (optimisticTaskUpdates.size === 0) { - // No optimistic updates, use incoming data as-is - setTasks(initialTasks); - } else { - // Merge incoming data with optimistic updates - const mergedTasks = initialTasks.map(task => { - const optimisticUpdate = optimisticTaskUpdates.get(task.id); - if (optimisticUpdate) { - console.log(`[TasksTab] Preserving optimistic update for task ${task.id}:`, optimisticUpdate.status); - // Clean up internal tracking field before returning - const { _optimisticOperationId, ...cleanTask } = optimisticUpdate; - return cleanTask as Task; // Keep optimistic version without internal fields - } - return task; // Use polling data for non-optimistic tasks - }); - setTasks(mergedTasks); - } - }, [initialTasks, optimisticTaskUpdates]); + // Fetch tasks and features using TanStack Query + const { data: tasks = [], isLoading: isLoadingTasks } = useProjectTasks(projectId); + const { data: featuresData, isLoading: isLoadingFeatures } = useProjectFeatures(projectId); + + // Mutations + const createTaskMutation = useCreateTask(); + const updateTaskMutation = useUpdateTask(projectId); + const deleteTaskMutation = useDeleteTask(projectId); - // Load project features on component mount - useEffect(() => { - loadProjectFeatures(); - }, [projectId]); - - - const loadProjectFeatures = async () => { - if (!projectId) return; - - setIsLoadingFeatures(true); - try { - const response = await projectService.getProjectFeatures(projectId); - setProjectFeatures(response.features || []); - } catch (error) { - console.error('Failed to load project features:', error); - setProjectFeatures([]); - } finally { - setIsLoadingFeatures(false); - } - }; + // Transform features data + const projectFeatures = useMemo(() => { + return featuresData?.features || []; + }, [featuresData]); // Modal management functions const openEditModal = async (task: Task) => { @@ -85,356 +44,242 @@ export const TasksTab = ({ setIsModalOpen(true); }; - const closeModal = () => { - setIsModalOpen(false); + const openCreateModal = () => { setEditingTask(null); + setIsModalOpen(true); }; - const saveTask = async (task: Task) => { - setEditingTask(task); + const closeModal = () => { + setEditingTask(null); + setIsModalOpen(false); + }; + + // Get default order for new tasks in a status + const getDefaultTaskOrder = (statusTasks: Task[], status: Task['status']) => { + if (statusTasks.length === 0) return 100; + const maxOrder = Math.max(...statusTasks.map(t => t.task_order)); + return maxOrder + 100; + }; + + // Calculate position between two tasks for reordering + const calculateReorderPosition = (statusTasks: Task[], fromIndex: number, toIndex: number) => { + // Moving to the beginning + if (toIndex === 0) { + return Math.max(1, Math.floor(statusTasks[0].task_order / 2)); + } + // Moving to the end + if (toIndex >= statusTasks.length) { + return statusTasks[statusTasks.length - 1].task_order + 100; + } + + // Moving between two tasks + // When moving down (fromIndex < toIndex), insert after toIndex + // When moving up (fromIndex > toIndex), insert before toIndex + if (fromIndex < toIndex) { + // Moving down - insert after toIndex + const afterTask = statusTasks[toIndex]; + const nextTask = statusTasks[toIndex + 1]; + if (nextTask) { + return Math.floor((afterTask.task_order + nextTask.task_order) / 2); + } else { + return afterTask.task_order + 100; + } + } else { + // Moving up - insert before toIndex + const beforeTask = toIndex > 0 ? statusTasks[toIndex - 1] : null; + const targetTask = statusTasks[toIndex]; + if (beforeTask) { + return Math.floor((beforeTask.task_order + targetTask.task_order) / 2); + } else { + return Math.max(1, Math.floor(targetTask.task_order / 2)); + } + } + }; + + // Save task (create or update) + const saveTask = async (taskData: Partial) => { setIsSavingTask(true); try { - - if (task.id) { - // Update existing task - const updateData: UpdateTaskRequest = { - title: task.title, - description: task.description, - status: task.status, - assignee: task.assignee?.name || 'User', - task_order: task.task_order, - ...(task.feature && { feature: task.feature }), - ...(task.featureColor && { featureColor: task.featureColor }) - }; + if (editingTask) { + // Update existing task - build updates object with only changed values + const updates: any = {}; - await projectService.updateTask(task.id, updateData); + // Only include fields that are defined (not null or undefined) + if (taskData.title !== undefined) updates.title = taskData.title; + if (taskData.description !== undefined) updates.description = taskData.description; + if (taskData.status !== undefined) updates.status = taskData.status; + if (taskData.assignee !== undefined) updates.assignee = taskData.assignee || 'User'; + if (taskData.task_order !== undefined) updates.task_order = taskData.task_order; + + // Feature can be empty string but not null/undefined + if (taskData.feature !== undefined && taskData.feature !== null) { + updates.feature = taskData.feature || ''; // Convert empty/null to empty string + } + + await updateTaskMutation.mutateAsync({ + taskId: editingTask.id, + updates + }); + closeModal(); } else { - // Create new task first to get UUID - const createData: CreateTaskRequest = { + // Create new task + const statusTasks = tasks.filter(t => t.status === (taskData.status || 'todo')); + const newTaskData: CreateTaskRequest = { project_id: projectId, - title: task.title, - description: task.description, - status: task.status, - assignee: task.assignee?.name || 'User', - task_order: task.task_order, - ...(task.feature && { feature: task.feature }), - ...(task.featureColor && { featureColor: task.featureColor }) + title: taskData.title || '', + description: taskData.description || '', + status: taskData.status || 'todo', + assignee: taskData.assignee || 'User', + feature: taskData.feature || '', + task_order: taskData.task_order || getDefaultTaskOrder(statusTasks, taskData.status || 'todo') }; - await projectService.createTask(createData); + await createTaskMutation.mutateAsync(newTaskData); + closeModal(); } - - // Task saved - polling will pick up changes automatically - closeModal(); } catch (error) { console.error('Failed to save task:', error); - showToast(`Failed to save task: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error'); + showToast('Failed to save task', 'error'); } finally { setIsSavingTask(false); } }; - // Update tasks helper - const updateTasks = (newTasks: Task[]) => { - setTasks(newTasks); - onTasksChange(newTasks); - }; - - - // Helper function to get next available order number for a status - const getNextOrderForStatus = (status: Task['status']): number => { - const tasksInStatus = tasks.filter(task => - task.status === status - ); - - if (tasksInStatus.length === 0) return 1; - - const maxOrder = Math.max(...tasksInStatus.map(task => task.task_order)); - return maxOrder + 1; - }; - - // Use shared debounce helper - - // Improved debounced persistence with better coordination - const debouncedPersistSingleTask = useMemo( - () => debounce(async (task: Task) => { - try { - console.log('REORDER: Persisting position change for task:', task.title, 'new position:', task.task_order); - - // Update only the moved task with server timestamp for conflict resolution - await projectService.updateTask(task.id, { - task_order: task.task_order, - client_timestamp: Date.now() - }); - console.log('REORDER: Single task position persisted successfully'); - - } catch (error) { - console.error('REORDER: Failed to persist task position:', error); - // Polling will eventually sync the correct state - } - }, 800), // Slightly reduced delay for better responsiveness - [] - ); - - // Optimized task reordering without optimistic update conflicts - const handleTaskReorder = useCallback((taskId: string, targetIndex: number, status: Task['status']) => { - console.log('REORDER: Moving task', taskId, 'to index', targetIndex, 'in status', status); - + // Task reordering - immediate update + const handleTaskReorder = useCallback(async (taskId: string, targetIndex: number, status: Task['status']) => { // Get all tasks in the target status, sorted by current order const statusTasks = tasks .filter(task => task.status === status) .sort((a, b) => a.task_order - b.task_order); - const otherTasks = tasks.filter(task => task.status !== status); - - // Find the moving task const movingTaskIndex = statusTasks.findIndex(task => task.id === taskId); - if (movingTaskIndex === -1) { - console.log('REORDER: Task not found in status'); - return; - } + if (movingTaskIndex === -1 || targetIndex < 0 || targetIndex >= statusTasks.length) return; + if (movingTaskIndex === targetIndex) return; - // Prevent invalid moves - if (targetIndex < 0 || targetIndex >= statusTasks.length) { - console.log('REORDER: Invalid target index', targetIndex); - return; - } - - // Skip if moving to same position - if (movingTaskIndex === targetIndex) { - console.log('REORDER: Task already in target position'); - return; - } - - const movingTask = statusTasks[movingTaskIndex]; - console.log('REORDER: Moving', movingTask.title, 'from', movingTaskIndex, 'to', targetIndex); - - // Calculate new position using shared ordering utility + // Calculate new position const newPosition = calculateReorderPosition(statusTasks, movingTaskIndex, targetIndex); - console.log('REORDER: New position calculated:', newPosition); - - // Create updated task with new position - const updatedTask = { - ...movingTask, - task_order: newPosition - }; - - // Immediate UI update without optimistic tracking interference - const allUpdatedTasks = otherTasks.concat( - statusTasks.map(task => task.id === taskId ? updatedTask : task) - ); - updateTasks(allUpdatedTasks); - - // Persist to backend (single API call) - debouncedPersistSingleTask(updatedTask); - }, [tasks, updateTasks, debouncedPersistSingleTask]); - - // Task move function (for board view) - Optimistic Updates with Concurrent Operation Protection - const moveTask = async (taskId: string, newStatus: Task['status']) => { - // Generate unique operation ID to handle concurrent operations - const operationId = `${taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - console.log(`[TasksTab] Optimistically moving task ${taskId} to ${newStatus} (op: ${operationId})`); - - // Clear any previous errors (removed local error state) - - // Find the task and validate - const movingTask = tasks.find(task => task.id === taskId); - if (!movingTask) { - showToast('Task not found', 'error'); - return; - } - - // (pendingOperations removed) - - // 1. Save current state for rollback - const previousTasks = [...tasks]; // Shallow clone sufficient - const newOrder = getNextOrderForStatus(newStatus); - - // 2. Update UI immediately (optimistic update - no loader!) - const optimisticTask: OptimisticTask = { - ...movingTask, - status: newStatus, - task_order: newOrder, - _optimisticOperationId: operationId // Track which operation created this - }; - const optimisticTasks = tasks.map(task => - task.id === taskId ? optimisticTask : task - ); - - // Track this as an optimistic update with operation ID - setOptimisticTaskUpdates(prev => new Map(prev).set(taskId, optimisticTask)); - updateTasks(optimisticTasks); - - // 3. Call API in background + // Update immediately with optimistic updates try { - await projectService.updateTask(taskId, { - status: newStatus, - task_order: newOrder, - client_timestamp: Date.now() - }); - - console.log(`[TasksTab] Successfully moved task ${taskId} (op: ${operationId})`); - - // Only clear if this is still the current operation (no newer operation started) - setOptimisticTaskUpdates(prev => { - const currentOptimistic = prev.get(taskId); - if (currentOptimistic?._optimisticOperationId === operationId) { - const newMap = new Map(prev); - newMap.delete(taskId); - return newMap; + await updateTaskMutation.mutateAsync({ + taskId, + updates: { + task_order: newPosition } - return prev; // Don't clear, newer operation is active }); - } catch (error) { - console.error(`[TasksTab] Failed to move task ${taskId} (op: ${operationId}):`, error); + console.error('Failed to reorder task:', error); + showToast('Failed to reorder task', 'error'); + } + }, [tasks, updateTaskMutation, showToast]); + + // Move task to different status + const moveTask = async (taskId: string, newStatus: Task['status']) => { + const movingTask = tasks.find(task => task.id === taskId); + if (!movingTask || movingTask.status === newStatus) return; + + try { + // Calculate position for new status + const tasksInNewStatus = tasks.filter(t => t.status === newStatus); + const newOrder = getDefaultTaskOrder(tasksInNewStatus, newStatus); - // Only rollback if this is still the current operation - setOptimisticTaskUpdates(prev => { - const currentOptimistic = prev.get(taskId); - if (currentOptimistic?._optimisticOperationId === operationId) { - // 4. Rollback on failure - revert to exact previous state - updateTasks(previousTasks); - - const newMap = new Map(prev); - newMap.delete(taskId); - - const errorMessage = error instanceof Error ? error.message : 'Failed to move task'; - showToast(`Failed to move task: ${errorMessage}`, 'error'); - - return newMap; + // Update via mutation (handles optimistic updates) + await updateTaskMutation.mutateAsync({ + taskId, + updates: { + status: newStatus, + task_order: newOrder } - return prev; // Don't rollback, newer operation is active }); - } finally { - // (pendingOperations cleanup removed) + showToast(`Task moved to ${newStatus}`, 'success'); + } catch (error) { + console.error('Failed to move task:', error); + showToast('Failed to move task', 'error'); } }; - const completeTask = (taskId: string) => { - console.log(`[TasksTab] Calling completeTask for ${taskId}`); + const completeTask = useCallback((taskId: string) => { moveTask(taskId, 'done'); - }; + }, []); const deleteTask = async (task: Task) => { try { - await projectService.deleteTask(task.id); - updateTasks(tasks.filter(t => t.id !== task.id)); - showToast(`Task "${task.title}" deleted`, 'success'); + await deleteTaskMutation.mutateAsync(task.id); } catch (error) { console.error('Failed to delete task:', error); - showToast('Failed to delete task', 'error'); + // Error handled by mutation } }; - // Inline task creation function - const createTaskInline = async (newTask: Omit) => { - try { - // Auto-assign next order number if not provided - const nextOrder = newTask.task_order || getNextOrderForStatus(newTask.status); - - const createData: CreateTaskRequest = { - project_id: projectId, - title: newTask.title, - description: newTask.description, - status: newTask.status, - assignee: newTask.assignee?.name || 'User', - task_order: nextOrder, - ...(newTask.feature && { feature: newTask.feature }), - ...(newTask.featureColor && { featureColor: newTask.featureColor }) - }; - - await projectService.createTask(createData); - - // Task created - polling will pick up changes automatically - console.log('[TasksTab] Task created successfully'); - - } catch (error) { - console.error('Failed to create task:', error); - throw error; - } - }; - - // Inline task update function - const updateTaskInline = async (taskId: string, updates: Partial) => { - console.log(`[TasksTab] Inline update for task ${taskId} with updates:`, updates); - try { - const updateData: Partial = { - client_timestamp: Date.now() - }; - - if (updates.title !== undefined) updateData.title = updates.title; - if (updates.description !== undefined) updateData.description = updates.description; - if (updates.status !== undefined) { - console.log(`[TasksTab] Setting status for ${taskId}: ${updates.status}`); - updateData.status = updates.status; - } - if (updates.assignee !== undefined) updateData.assignee = updates.assignee.name; - if (updates.task_order !== undefined) updateData.task_order = updates.task_order; - if (updates.feature !== undefined) updateData.feature = updates.feature; - if (updates.featureColor !== undefined) updateData.featureColor = updates.featureColor; - - console.log(`[TasksTab] Sending update request for task ${taskId} to projectService:`, updateData); - await projectService.updateTask(taskId, updateData); - console.log(`[TasksTab] projectService.updateTask successful for ${taskId}.`); - - // Task updated - polling will pick up changes automatically - console.log(`[TasksTab] Task ${taskId} updated successfully`); - - } catch (error) { - console.error(`[TasksTab] Failed to update task ${taskId} inline:`, error); - showToast(`Failed to update task: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error'); - throw error; - } - }; - - // Get tasks for priority selection with descriptive labels - const getTasksForPrioritySelection = (status: Task['status']): Array<{value: number, label: string}> => { + // Get task priority selection options + const getTasksForPrioritySelection = useCallback((status: Task['status']) => { const tasksInStatus = tasks - .filter(task => task.status === status && task.id !== editingTask?.id) // Exclude current task if editing + .filter(task => task.status === status && task.id !== editingTask?.id) .sort((a, b) => a.task_order - b.task_order); const options: Array<{value: number, label: string}> = []; if (tasksInStatus.length === 0) { // No tasks in this status - options.push({ value: 1, label: "1 - First task in this status" }); + options.push({ value: 100, label: "First task in this status" }); } else { // Add option to be first options.push({ - value: 1, - label: `1 - Before "${tasksInStatus[0].title.substring(0, 30)}${tasksInStatus[0].title.length > 30 ? '...' : ''}"` + value: Math.max(1, Math.floor(tasksInStatus[0].task_order / 2)), + label: `Before "${tasksInStatus[0].title.substring(0, 30)}${tasksInStatus[0].title.length > 30 ? '...' : ''}"` }); // Add options between existing tasks for (let i = 0; i < tasksInStatus.length - 1; i++) { const currentTask = tasksInStatus[i]; const nextTask = tasksInStatus[i + 1]; + const midPoint = Math.floor((currentTask.task_order + nextTask.task_order) / 2); options.push({ - value: i + 2, - label: `${i + 2} - After "${currentTask.title.substring(0, 20)}${currentTask.title.length > 20 ? '...' : ''}", Before "${nextTask.title.substring(0, 20)}${nextTask.title.length > 20 ? '...' : ''}"` + value: midPoint, + label: `Between "${currentTask.title.substring(0, 20)}${currentTask.title.length > 20 ? '...' : ''}" and "${nextTask.title.substring(0, 20)}${nextTask.title.length > 20 ? '...' : ''}"` }); } // Add option to be last const lastTask = tasksInStatus[tasksInStatus.length - 1]; options.push({ - value: tasksInStatus.length + 1, - label: `${tasksInStatus.length + 1} - After "${lastTask.title.substring(0, 30)}${lastTask.title.length > 30 ? '...' : ''}"` + value: lastTask.task_order + 100, + label: `After "${lastTask.title.substring(0, 30)}${lastTask.title.length > 30 ? '...' : ''}"` }); } return options; + }, [tasks, editingTask?.id]); + + // Inline update for task fields + const updateTaskInline = async (taskId: string, updates: Partial) => { + try { + // Ensure task_order is an integer if present + const processedUpdates: any = { ...updates }; + if (processedUpdates.task_order !== undefined) { + processedUpdates.task_order = Math.round(processedUpdates.task_order); + } + // Assignee is already a string, no conversion needed + + await updateTaskMutation.mutateAsync({ + taskId, + updates: processedUpdates + }); + } catch (error) { + console.error('Failed to update task:', error); + showToast('Failed to update task', 'error'); + } }; - // Memoized version of getTasksForPrioritySelection to prevent recalculation on every render - const memoizedGetTasksForPrioritySelection = useMemo( - () => getTasksForPrioritySelection, - [tasks, editingTask?.id] - ); + if (isLoadingTasks) { + return ( +
+
+
+ ); + } return ( @@ -448,16 +293,23 @@ export const TasksTab = ({ onTaskComplete={completeTask} onTaskDelete={deleteTask} onTaskReorder={handleTaskReorder} - onTaskCreate={createTaskInline} + onTaskCreate={async (task) => { + await createTaskMutation.mutateAsync({ + ...task, + project_id: projectId, + assignee: task.assignee || 'User', // Already a string + task_order: Math.round(task.task_order) // Ensure integer + }); + }} onTaskUpdate={updateTaskInline} /> ) : ( )} @@ -470,17 +322,9 @@ export const TasksTab = ({ {/* Add Task Button with Luminous Style */}
- {/* Edit Task Modal */} + {/* Edit/Create Task Modal */} diff --git a/archon-ui-main/src/hooks/useCrawlQueries.ts b/archon-ui-main/src/hooks/useCrawlQueries.ts new file mode 100644 index 00000000..b5d0bc8d --- /dev/null +++ b/archon-ui-main/src/hooks/useCrawlQueries.ts @@ -0,0 +1,433 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; +import { knowledgeBaseService, KnowledgeItem } from '../services/knowledgeBaseService'; +import { CrawlProgressData } from '../types/crawl'; +import { useToast } from '../contexts/ToastContext'; + +// Query keys factory +export const crawlKeys = { + all: ['crawl'] as const, + progress: (progressId: string) => [...crawlKeys.all, 'progress', progressId] as const, +}; + +export const knowledgeKeys = { + all: ['knowledge'] as const, + items: () => [...knowledgeKeys.all, 'items'] as const, + item: (id: string) => [...knowledgeKeys.all, 'item', id] as const, + search: (query: string) => [...knowledgeKeys.all, 'search', query] as const, +}; + +// Fetch crawl progress +export function useCrawlProgressPolling(progressId: string | null, options?: any) { + const [isComplete, setIsComplete] = useState(false); + + // Reset complete state when progressId changes + useEffect(() => { + console.log(`📊 Progress ID changed to: ${progressId}, resetting complete state`); + setIsComplete(false); + }, [progressId]); + + const handleError = useCallback((error: Error) => { + // Handle permanent resource not found + if (error.message === 'Resource no longer exists') { + console.log(`Crawl progress no longer exists for: ${progressId}`); + + // Clean up from localStorage + if (progressId) { + localStorage.removeItem(`crawl_progress_${progressId}`); + const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]'); + const updated = activeCrawls.filter((id: string) => id !== progressId); + localStorage.setItem('active_crawls', JSON.stringify(updated)); + } + + options?.onError?.(error); + return; + } + + // Log other errors + if (!error.message.includes('404') && !error.message.includes('Not Found') && + !error.message.includes('ERR_INSUFFICIENT_RESOURCES')) { + console.error('Crawl progress error:', error); + } + + options?.onError?.(error); + }, [progressId, options]); + + const query = useQuery({ + queryKey: crawlKeys.progress(progressId!), + queryFn: async () => { + if (!progressId) throw new Error('No progress ID'); + + const response = await fetch(`/api/progress/${progressId}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include', + }); + + if (response.status === 404) { + // Track consecutive 404s + const notFoundKey = `crawl_404_${progressId}`; + const notFoundCount = parseInt(localStorage.getItem(notFoundKey) || '0') + 1; + localStorage.setItem(notFoundKey, notFoundCount.toString()); + + if (notFoundCount >= 5) { + localStorage.removeItem(notFoundKey); + throw new Error('Resource no longer exists'); + } + + console.log(`Resource not found (404), attempt ${notFoundCount}/5: ${progressId}`); + return null; + } + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); + } + + // Reset 404 counter on success + localStorage.removeItem(`crawl_404_${progressId}`); + + return response.json(); + }, + enabled: !!progressId && !isComplete, + refetchInterval: 1000, // Poll every second + retry: false, // Don't retry on error + staleTime: 0, // Always refetch + onError: handleError, + }); + + // Stop polling when operation is complete or failed + useEffect(() => { + const status = query.data?.status; + if (query.data) { + console.debug('🔄 Crawl polling data received:', { + progressId, + status, + progress: query.data.progress + }); + } + if (status === 'completed' || status === 'failed' || status === 'error' || status === 'cancelled') { + console.debug('âšī¸ Crawl polling stopping - status:', status); + setIsComplete(true); + } + }, [query.data?.status, progressId]); + + // Transform data to expected format + const transformedData = query.data ? { + ...query.data, + progress: query.data.progress || 0, + logs: query.data.logs || [], + message: query.data.message || '', + } : null; + + return { + ...query, + data: transformedData, + isComplete + }; +} + +// ==================== KNOWLEDGE BASE QUERIES ==================== + +// Fetch knowledge items +export function useKnowledgeItems(page = 1, perPage = 100) { + return useQuery({ + queryKey: knowledgeKeys.items(), + queryFn: async () => { + const response = await knowledgeBaseService.getKnowledgeItems({ + page, + per_page: perPage + }); + return response; + }, + staleTime: 30000, // Consider data stale after 30 seconds + cacheTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + }); +} + +// Delete knowledge item mutation +export function useDeleteKnowledgeItem() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: async (sourceId: string) => { + return await knowledgeBaseService.deleteKnowledgeItem(sourceId); + }, + onSuccess: (data, sourceId) => { + // Optimistically update the cache + queryClient.setQueryData(knowledgeKeys.items(), (old: any) => { + if (!old) return old; + return { + ...old, + items: old.items.filter((item: KnowledgeItem) => item.source_id !== sourceId), + total: old.total - 1 + }; + }); + + showToast('Item deleted successfully', 'success'); + }, + onError: (error) => { + showToast('Failed to delete item', 'error'); + console.error('Delete failed:', error); + } + }); +} + +// Delete multiple items mutation +export function useDeleteMultipleItems() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: async (sourceIds: string[]) => { + const deletePromises = sourceIds.map(id => + knowledgeBaseService.deleteKnowledgeItem(id) + ); + return await Promise.all(deletePromises); + }, + onSuccess: (data, sourceIds) => { + // Optimistically update the cache + queryClient.setQueryData(knowledgeKeys.items(), (old: any) => { + if (!old) return old; + const idsSet = new Set(sourceIds); + return { + ...old, + items: old.items.filter((item: KnowledgeItem) => !idsSet.has(item.source_id)), + total: old.total - sourceIds.length + }; + }); + + showToast(`Deleted ${sourceIds.length} items successfully`, 'success'); + }, + onError: (error) => { + showToast('Failed to delete some items', 'error'); + console.error('Batch delete failed:', error); + } + }); +} + +// Refresh knowledge item mutation +export function useRefreshKnowledgeItem() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: async (sourceId: string) => { + return await knowledgeBaseService.refreshKnowledgeItem(sourceId); + }, + onSuccess: (data, sourceId) => { + // Remove the item from cache as it's being refreshed + queryClient.setQueryData(knowledgeKeys.items(), (old: any) => { + if (!old) return old; + return { + ...old, + items: old.items.filter((item: KnowledgeItem) => item.source_id !== sourceId) + }; + }); + + showToast('Refresh started', 'info'); + }, + onError: (error) => { + showToast('Failed to refresh item', 'error'); + console.error('Refresh failed:', error); + } + }); +} + +// Crawl URL mutation +export function useCrawlUrl() { + const { showToast } = useToast(); + + return useMutation({ + mutationFn: async (params: any) => { + return await knowledgeBaseService.crawlUrl(params); + }, + onSuccess: (data) => { + if (data.progressId) { + showToast('Crawl started successfully', 'success'); + } + }, + onError: (error) => { + showToast('Failed to start crawl', 'error'); + console.error('Crawl failed:', error); + } + }); +} + +// Upload document mutation +export function useUploadDocument() { + const { showToast } = useToast(); + + return useMutation({ + mutationFn: async ({ file, metadata }: { file: File, metadata: any }) => { + return await knowledgeBaseService.uploadDocument(file, metadata); + }, + onSuccess: (data) => { + if (data.progressId) { + showToast('Document upload started', 'success'); + } + }, + onError: (error) => { + showToast('Failed to upload document', 'error'); + console.error('Upload failed:', error); + } + }); +} + +// Stop crawl mutation +export function useStopCrawl() { + const { showToast } = useToast(); + + return useMutation({ + mutationFn: async (progressId: string) => { + return await knowledgeBaseService.stopCrawl(progressId); + }, + onSuccess: () => { + showToast('Crawl stopped', 'info'); + }, + onError: (error) => { + showToast('Failed to stop crawl', 'error'); + console.error('Stop crawl failed:', error); + } + }); +} + +// Create group mutation +export function useCreateGroup() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: async ({ items, groupName }: { items: KnowledgeItem[], groupName: string }) => { + const updatePromises = items.map(item => + knowledgeBaseService.updateKnowledgeItem(item.source_id, { + metadata: { + ...item.metadata, + group_name: groupName + } + }) + ); + return await Promise.all(updatePromises); + }, + onSuccess: (data, variables) => { + // Invalidate the cache to refetch with new groups + queryClient.invalidateQueries({ queryKey: knowledgeKeys.items() }); + showToast(`Created group "${variables.groupName}" with ${variables.items.length} items`, 'success'); + }, + onError: (error) => { + showToast('Failed to create group', 'error'); + console.error('Group creation failed:', error); + } + }); +} + +// Custom hook to manage crawl progress state +export function useCrawlProgressManager() { + const [progressItems, setProgressItems] = useState([]); + const queryClient = useQueryClient(); + + // Load active crawls from localStorage on mount + useEffect(() => { + const activeCrawlsStr = localStorage.getItem('active_crawls'); + const activeCrawls = JSON.parse(activeCrawlsStr || '[]'); + + if (activeCrawls.length > 0) { + const restoredItems: CrawlProgressData[] = []; + const staleItems: string[] = []; + + for (const crawlId of activeCrawls) { + const crawlData = localStorage.getItem(`crawl_progress_${crawlId}`); + + if (crawlData) { + try { + const parsed = JSON.parse(crawlData); + const isCompleted = ['completed', 'error', 'failed', 'cancelled'].includes(parsed.status); + const now = Date.now(); + const startedAt = parsed.startedAt || now; + const ageMinutes = (now - startedAt) / (1000 * 60); + const isStale = ageMinutes > 5; + + if (isCompleted || isStale) { + staleItems.push(crawlId); + } else { + restoredItems.push({ + ...parsed, + progressId: crawlId, + }); + } + } catch { + staleItems.push(crawlId); + } + } else { + staleItems.push(crawlId); + } + } + + // Clean up stale items + if (staleItems.length > 0) { + const updatedCrawls = activeCrawls.filter((id: string) => !staleItems.includes(id)); + localStorage.setItem('active_crawls', JSON.stringify(updatedCrawls)); + staleItems.forEach(id => { + localStorage.removeItem(`crawl_progress_${id}`); + }); + } + + // Set restored items + if (restoredItems.length > 0) { + setProgressItems(restoredItems); + } + } + }, []); + + const addProgressItem = useCallback((item: CrawlProgressData) => { + setProgressItems(prev => { + const existing = prev.find(p => p.progressId === item.progressId); + if (existing) { + return prev.map(p => p.progressId === item.progressId ? item : p); + } + return [...prev, item]; + }); + + // Store in localStorage + localStorage.setItem(`crawl_progress_${item.progressId}`, JSON.stringify({ + ...item, + startedAt: Date.now() + })); + + const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]'); + if (!activeCrawls.includes(item.progressId)) { + activeCrawls.push(item.progressId); + localStorage.setItem('active_crawls', JSON.stringify(activeCrawls)); + } + }, []); + + const removeProgressItem = useCallback((progressId: string) => { + setProgressItems(prev => prev.filter(item => item.progressId !== progressId)); + + // Clean up localStorage + localStorage.removeItem(`crawl_progress_${progressId}`); + const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]'); + const updated = activeCrawls.filter((id: string) => id !== progressId); + localStorage.setItem('active_crawls', JSON.stringify(updated)); + }, []); + + const updateProgressItem = useCallback((progressId: string, updates: Partial) => { + setProgressItems(prev => prev.map(item => + item.progressId === progressId ? { ...item, ...updates } : item + )); + }, []); + + const completeProgressItem = useCallback((progressId: string) => { + removeProgressItem(progressId); + // Invalidate knowledge items to show the new item + queryClient.invalidateQueries({ queryKey: knowledgeKeys.items() }); + }, [removeProgressItem, queryClient]); + + return { + progressItems, + addProgressItem, + removeProgressItem, + updateProgressItem, + completeProgressItem, + }; +} \ No newline at end of file diff --git a/archon-ui-main/src/hooks/useDatabaseMutation.ts b/archon-ui-main/src/hooks/useDatabaseMutation.ts deleted file mode 100644 index d12e2de2..00000000 --- a/archon-ui-main/src/hooks/useDatabaseMutation.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; - -interface UseDatabaseMutationOptions { - onSuccess?: (data: TData) => void; - onError?: (error: Error) => void; - invalidateCache?: () => void; - successMessage?: string; - errorMessage?: string; - showSuccessToast?: boolean; - showErrorToast?: boolean; -} - -interface UseDatabaseMutationResult { - mutate: (variables: TVariables) => Promise; - mutateAsync: (variables: TVariables) => Promise; - isLoading: boolean; - isError: boolean; - isSuccess: boolean; - error: Error | null; - data: TData | undefined; - reset: () => void; -} - -/** - * Database-first mutation hook with loading states and error handling - * - * Features: - * - Shows loading state during operation - * - Waits for database confirmation before UI update - * - Displays errors immediately for debugging - * - Invalidates related queries after success - * - NO optimistic updates - */ -export function useDatabaseMutation( - mutationFn: (variables: TVariables) => Promise, - options: UseDatabaseMutationOptions = {} -): UseDatabaseMutationResult { - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); - const [isSuccess, setIsSuccess] = useState(false); - const [error, setError] = useState(null); - const [data, setData] = useState(undefined); - - // Track if component is still mounted to prevent state updates after unmount - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - const { - onSuccess, - onError, - invalidateCache, - successMessage = 'Operation completed successfully', - errorMessage = 'Operation failed', - showSuccessToast = false, - showErrorToast = true, - } = options; - - const reset = useCallback(() => { - if (isMountedRef.current) { - setIsLoading(false); - setIsError(false); - setIsSuccess(false); - setError(null); - setData(undefined); - } - }, []); - - const mutateAsync = useCallback(async (variables: TVariables): Promise => { - // Only update state if still mounted - if (isMountedRef.current) { - setIsLoading(true); - setIsError(false); - setIsSuccess(false); - setError(null); - } - - try { - const result = await mutationFn(variables); - - // Only update state and call callbacks if still mounted - if (isMountedRef.current) { - setData(result); - setIsSuccess(true); - - // Invalidate cache if specified - if (invalidateCache) { - invalidateCache(); - } - - // Call success callback if provided - if (onSuccess) { - onSuccess(result); - } - - // Show success toast if enabled - if (showSuccessToast && typeof window !== 'undefined' && (window as any).toast) { - (window as any).toast.success(successMessage); - } - } - - return result; - } catch (err) { - const error = err instanceof Error ? err : new Error('Unknown error'); - - // Only update state and call callbacks if still mounted - if (isMountedRef.current) { - setError(error); - setIsError(true); - - // Call error callback if provided - if (onError) { - onError(error); - } - - // Show error toast if enabled (default) - if (showErrorToast && typeof window !== 'undefined' && (window as any).toast) { - (window as any).toast.error(`${errorMessage}: ${error.message}`); - } - - // Log for debugging in beta - console.error('Database operation failed:', error); - } - - throw error; - } finally { - if (isMountedRef.current) { - setIsLoading(false); - } - } - }, [mutationFn, onSuccess, onError, invalidateCache, successMessage, errorMessage, showSuccessToast, showErrorToast]); - - const mutate = useCallback(async (variables: TVariables): Promise => { - try { - await mutateAsync(variables); - } catch { - // Error already handled in mutateAsync - } - }, [mutateAsync]); - - return { - mutate, - mutateAsync, - isLoading, - isError, - isSuccess, - error, - data, - reset, - }; -} - -/** - * Hook for mutations with inline loading indicator - */ -export function useAsyncMutation( - mutationFn: (variables: TVariables) => Promise -) { - const [isLoading, setIsLoading] = useState(false); - - // Track if component is still mounted to prevent state updates after unmount - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - const execute = useCallback(async (variables: TVariables): Promise => { - if (isMountedRef.current) { - setIsLoading(true); - } - try { - const result = await mutationFn(variables); - return result; - } catch (error) { - console.error('Async mutation failed:', error); - throw error; - } finally { - if (isMountedRef.current) { - setIsLoading(false); - } - } - }, [mutationFn]); - - return { execute, isLoading }; -} \ No newline at end of file diff --git a/archon-ui-main/src/hooks/useMCPQueries.ts b/archon-ui-main/src/hooks/useMCPQueries.ts new file mode 100644 index 00000000..c3185644 --- /dev/null +++ b/archon-ui-main/src/hooks/useMCPQueries.ts @@ -0,0 +1,77 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { mcpServerService } from '../services/mcpServerService'; +import { useToast } from '../contexts/ToastContext'; + +// Query keys +export const mcpKeys = { + all: ['mcp'] as const, + status: () => [...mcpKeys.all, 'status'] as const, + config: () => [...mcpKeys.all, 'config'] as const, + tools: () => [...mcpKeys.all, 'tools'] as const, +}; + +// Fetch MCP server status +export function useMCPStatus() { + return useQuery({ + queryKey: mcpKeys.status(), + queryFn: () => mcpServerService.getStatus(), + staleTime: 5 * 60 * 1000, // 5 minutes - status rarely changes + refetchOnWindowFocus: false, + }); +} + +// Fetch MCP server config +export function useMCPConfig(enabled = true) { + return useQuery({ + queryKey: mcpKeys.config(), + queryFn: () => mcpServerService.getConfiguration(), + enabled, + staleTime: Infinity, // Config never changes unless server restarts + }); +} + +// Start server mutation +export function useStartMCPServer() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: () => mcpServerService.startServer(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: mcpKeys.status() }); + queryClient.invalidateQueries({ queryKey: mcpKeys.config() }); + showToast('MCP server started successfully', 'success'); + }, + onError: (error: any) => { + showToast(error.message || 'Failed to start server', 'error'); + }, + }); +} + +// Stop server mutation +export function useStopMCPServer() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: () => mcpServerService.stopServer(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: mcpKeys.status() }); + queryClient.removeQueries({ queryKey: mcpKeys.config() }); + showToast('MCP server stopped', 'info'); + }, + onError: (error: any) => { + showToast(error.message || 'Failed to stop server', 'error'); + }, + }); +} + +// List MCP tools +export function useMCPTools(enabled = true) { + return useQuery({ + queryKey: mcpKeys.tools(), + queryFn: () => mcpServerService.listTools(), + enabled, + staleTime: Infinity, // Tools don't change during runtime + }); +} \ No newline at end of file diff --git a/archon-ui-main/src/hooks/usePolling.ts b/archon-ui-main/src/hooks/usePolling.ts deleted file mode 100644 index cccbe31c..00000000 --- a/archon-ui-main/src/hooks/usePolling.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; - -interface UsePollingOptions { - interval?: number; - enabled?: boolean; - onError?: (error: Error) => void; - onSuccess?: (data: T) => void; - staleTime?: number; -} - -interface UsePollingResult { - data: T | undefined; - error: Error | null; - isLoading: boolean; - isError: boolean; - isSuccess: boolean; - refetch: () => Promise; -} - -/** - * Generic polling hook with visibility and focus detection - * - * Features: - * - Stops polling when tab is hidden - * - Resumes polling when tab becomes visible - * - Immediate refetch on focus - * - ETag support for efficient polling - */ -export function usePolling( - url: string, - options: UsePollingOptions = {} -): UsePollingResult { - const { - interval = 3000, - enabled = true, - onError, - onSuccess, - staleTime = 0 - } = options; - - const [data, setData] = useState(undefined); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [pollInterval, setPollInterval] = useState(enabled ? interval : 0); - - const etagRef = useRef(null); - const intervalRef = useRef | null>(null); - const cachedDataRef = useRef(undefined); - const lastFetchRef = useRef(0); - const notFoundCountRef = useRef(0); // Track consecutive 404s - - // Reset ETag/cache on URL change to avoid cross-endpoint contamination - useEffect(() => { - etagRef.current = null; - cachedDataRef.current = undefined; - lastFetchRef.current = 0; - }, [url]); - - const fetchData = useCallback(async (force = false) => { - // Don't fetch if URL is empty - if (!url) { - return; - } - - // Check stale time - if (!force && staleTime > 0 && Date.now() - lastFetchRef.current < staleTime) { - return; // Data is still fresh - } - - try { - const headers: HeadersInit = { - Accept: 'application/json', - }; - - // Include ETag if we have one for this URL (unless forcing refresh) - if (etagRef.current && !force) { - headers['If-None-Match'] = etagRef.current; - } - - const response = await fetch(url, { - method: 'GET', - headers, - credentials: 'include', - }); - - // Handle 304 Not Modified - data hasn't changed - if (response.status === 304) { - // Return cached data - if (cachedDataRef.current !== undefined) { - setData(cachedDataRef.current); - if (onSuccess) { - onSuccess(cachedDataRef.current); - } - } - // Update fetch time to respect staleTime - lastFetchRef.current = Date.now(); - return; - } - - if (!response.ok) { - // For 404s, track consecutive failures - if (response.status === 404) { - notFoundCountRef.current++; - - // After 5 consecutive 404s (5 seconds), stop polling and call error handler - if (notFoundCountRef.current >= 5) { - console.error(`Resource permanently not found after ${notFoundCountRef.current} attempts: ${url}`); - const error = new Error('Resource no longer exists'); - setError(error); - setPollInterval(0); // Stop polling - if (onError) { - onError(error); - } - return; - } - - console.log(`Resource not found (404), attempt ${notFoundCountRef.current}/5: ${url}`); - lastFetchRef.current = Date.now(); - setError(null); - return; - } - throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); - } - - // Reset 404 counter on successful response - notFoundCountRef.current = 0; - - // Store ETag for next request - const etag = response.headers.get('ETag'); - if (etag) { - etagRef.current = etag; - } - - const jsonData = await response.json(); - setData(jsonData); - cachedDataRef.current = jsonData; - lastFetchRef.current = Date.now(); - setError(null); - - // Call success callback if provided - if (onSuccess) { - onSuccess(jsonData); - } - } catch (err) { - console.error('Polling error:', err); - const error = err instanceof Error ? err : new Error('Unknown error'); - setError(error); - if (onError) { - onError(error); - } - } finally { - setIsLoading(false); - } - }, [url, staleTime, onSuccess, onError]); - - // Handle visibility change - useEffect(() => { - const handleVisibilityChange = () => { - if (document.hidden) { - setPollInterval(0); // Stop polling when hidden - } else { - setPollInterval(interval); // Resume polling when visible - // Trigger immediate refetch if URL exists - if (url && enabled) { - fetchData(); - } - } - }; - - const handleFocus = () => { - // Immediate refetch on focus if URL exists - if (url && enabled) { - fetchData(); - } - setPollInterval(interval); - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - window.addEventListener('focus', handleFocus); - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('focus', handleFocus); - }; - }, [interval, fetchData, url, enabled]); - - // Update polling interval when enabled changes - useEffect(() => { - setPollInterval(enabled && !document.hidden ? interval : 0); - }, [enabled, interval]); - - // Set up polling - useEffect(() => { - if (!url || !enabled) return; - - // Initial fetch - fetchData(); - - // Clear existing interval - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - - // Set up new interval if polling is enabled - if (pollInterval > 0) { - intervalRef.current = setInterval(fetchData, pollInterval); - } - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - }, [url, pollInterval, enabled, fetchData]); - - return { - data, - error, - isLoading, - isError: !!error, - isSuccess: !isLoading && !error && data !== undefined, - refetch: () => fetchData(true) - }; -} - -/** - * Hook for polling task updates - */ -export function useTaskPolling(projectId: string, options?: UsePollingOptions) { - const baseUrl = '/api/projects'; - const url = `${baseUrl}/${projectId}/tasks`; - - return usePolling(url, { - interval: 8000, // 8 seconds for tasks - staleTime: 2000, // Consider data stale after 2 seconds - ...options, - }); -} - -/** - * Hook for polling project list - */ -export function useProjectPolling(options?: UsePollingOptions) { - const url = '/api/projects'; - - return usePolling(url, { - interval: 10000, // 10 seconds for project list - staleTime: 3000, // Consider data stale after 3 seconds - ...options, - }); -} - - -/** - * Hook for polling crawl progress updates - */ -export function useCrawlProgressPolling(progressId: string | null, options?: UsePollingOptions) { - const url = progressId ? `/api/progress/${progressId}` : ''; - - console.log(`🔍 useCrawlProgressPolling called with progressId: ${progressId}, url: ${url}`); - - // Track if crawl is complete to disable polling - const [isComplete, setIsComplete] = useState(false); - - // Reset complete state when progressId changes - useEffect(() => { - console.log(`📊 Progress ID changed to: ${progressId}, resetting complete state`); - setIsComplete(false); - }, [progressId]); - - // Memoize the error handler to prevent recreating it on every render - const handleError = useCallback((error: Error) => { - // Handle permanent resource not found (after 5 consecutive 404s) - if (error.message === 'Resource no longer exists') { - console.log(`Crawl progress no longer exists for: ${progressId}`); - - // Clean up from localStorage - if (progressId) { - localStorage.removeItem(`crawl_progress_${progressId}`); - const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]'); - const updated = activeCrawls.filter((id: string) => id !== progressId); - localStorage.setItem('active_crawls', JSON.stringify(updated)); - } - - // Pass error to parent if provided - options?.onError?.(error); - return; - } - - // Log other errors - if (!error.message.includes('404') && !error.message.includes('Not Found') && - !error.message.includes('ERR_INSUFFICIENT_RESOURCES')) { - console.error('Crawl progress error:', error); - } - - // Pass error to parent if provided - options?.onError?.(error); - }, [progressId, options]); - - const result = usePolling(url, { - interval: 1000, // 1 second for crawl progress - enabled: !!progressId && !isComplete, - staleTime: 0, // Always refetch progress - onError: handleError, - }); - - // Stop polling when operation is complete or failed - useEffect(() => { - const status = result.data?.status; - if (result.data) { - console.debug('🔄 Crawl polling data received:', { - progressId, - status, - progress: result.data.progress - }); - } - if (status === 'completed' || status === 'failed' || status === 'error' || status === 'cancelled') { - console.debug('âšī¸ Crawl polling stopping - status:', status); - setIsComplete(true); - } - }, [result.data?.status, progressId]); - - // Backend now returns flattened, camelCase response - no transformation needed! - const transformedData = result.data ? { - ...result.data, - // Ensure we have required fields with defaults - progress: result.data.progress || 0, - logs: result.data.logs || [], - message: result.data.message || '', - } : null; - - return { - ...result, - data: transformedData, - isComplete - }; -} \ No newline at end of file diff --git a/archon-ui-main/src/hooks/useProjectMutation.ts b/archon-ui-main/src/hooks/useProjectMutation.ts deleted file mode 100644 index 577719ec..00000000 --- a/archon-ui-main/src/hooks/useProjectMutation.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; -import { useToast } from '../contexts/ToastContext'; - -interface UseProjectMutationOptions { - onSuccess?: (data: TData, variables: TVariables) => void; - onError?: (error: Error) => void; - successMessage?: string; - errorMessage?: string; -} - -interface UseProjectMutationResult { - mutate: (variables: TVariables) => Promise; - mutateAsync: (variables: TVariables) => Promise; - isPending: boolean; - isError: boolean; - isSuccess: boolean; - error: Error | null; - data: TData | undefined; -} - -/** - * Project-specific mutation hook - * Similar to useDatabaseMutation but tailored for project operations - */ -export function useProjectMutation( - _key: unknown, // For compatibility with old API, not used - mutationFn: (variables: TVariables) => Promise, - options: UseProjectMutationOptions = {} -): UseProjectMutationResult { - const { showToast } = useToast(); - const [isPending, setIsPending] = useState(false); - const [isError, setIsError] = useState(false); - const [isSuccess, setIsSuccess] = useState(false); - const [error, setError] = useState(null); - const [data, setData] = useState(undefined); - - // Track if component is still mounted to prevent state updates after unmount - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - const { - onSuccess, - onError, - successMessage = 'Operation completed successfully', - errorMessage = 'Operation failed', - } = options; - - const mutateAsync = useCallback(async (variables: TVariables): Promise => { - // Only update state if still mounted - if (isMountedRef.current) { - setIsPending(true); - setIsError(false); - setIsSuccess(false); - setError(null); - } - - try { - const result = await mutationFn(variables); - - // Only update state and call callbacks if still mounted - if (isMountedRef.current) { - setData(result); - setIsSuccess(true); - - // Call success callback if provided - if (onSuccess) { - onSuccess(result, variables); - } - - // Show success message if available - if (successMessage) { - showToast(successMessage, 'success'); - } - } - - return result; - } catch (err) { - const error = err instanceof Error ? err : new Error('Unknown error'); - - // Only update state and call callbacks if still mounted - if (isMountedRef.current) { - setError(error); - setIsError(true); - - // Call error callback if provided - if (onError) { - onError(error); - } - - // Show error message - showToast(errorMessage, 'error'); - } - - throw error; - } finally { - if (isMountedRef.current) { - setIsPending(false); - } - } - }, [mutationFn, onSuccess, onError, successMessage, errorMessage]); - - const mutate = useCallback(async (variables: TVariables): Promise => { - try { - await mutateAsync(variables); - } catch { - // Error already handled in mutateAsync - } - }, [mutateAsync]); - - return { - mutate, - mutateAsync, - isPending, - isError, - isSuccess, - error, - data, - }; -} \ No newline at end of file diff --git a/archon-ui-main/src/hooks/useProjectQueries.ts b/archon-ui-main/src/hooks/useProjectQueries.ts new file mode 100644 index 00000000..72f8f4f7 --- /dev/null +++ b/archon-ui-main/src/hooks/useProjectQueries.ts @@ -0,0 +1,254 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { projectService } from '../services/projectService'; +import type { Project, CreateProjectRequest, UpdateProjectRequest } from '../types/project'; +import type { Task } from '../components/project-tasks/TaskTableView'; +import { useToast } from '../contexts/ToastContext'; + +// Query keys factory for better organization +export const projectKeys = { + all: ['projects'] as const, + lists: () => [...projectKeys.all, 'list'] as const, + list: (filters?: any) => [...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, +}; + +// Fetch all projects +export function useProjects() { + return useQuery({ + queryKey: projectKeys.lists(), + queryFn: () => projectService.listProjects(), + refetchInterval: 10000, // Poll every 10 seconds + staleTime: 3000, // Consider data stale after 3 seconds + }); +} + +// Fetch tasks for a specific project +export function useProjectTasks(projectId: string | undefined, enabled = true) { + return useQuery({ + queryKey: projectKeys.tasks(projectId!), + queryFn: () => projectService.getTasksByProject(projectId!), + enabled: !!projectId && enabled, + refetchInterval: 8000, // Poll every 8 seconds + staleTime: 2000, // Consider data stale after 2 seconds + }); +} + +// Fetch task counts for all projects +export function useTaskCounts() { + return useQuery({ + queryKey: projectKeys.taskCounts(), + queryFn: () => projectService.getTaskCountsForAllProjects(), + refetchInterval: false, // Don't poll, only refetch manually + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); +} + +// Fetch project features +export function useProjectFeatures(projectId: string | undefined) { + return useQuery({ + queryKey: projectKeys.features(projectId!), + queryFn: () => projectService.getProjectFeatures(projectId!), + enabled: !!projectId, + staleTime: 30000, // Cache for 30 seconds + }); +} + +// Create project mutation +export function useCreateProject() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: (projectData: CreateProjectRequest) => + projectService.createProject(projectData), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + showToast('Project created successfully!', 'success'); + }, + onError: (error) => { + console.error('Failed to create project:', error); + showToast('Failed to create project', 'error'); + }, + }); +} + +// Update project mutation (for pinning, etc.) +export function useUpdateProject() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: ({ projectId, updates }: { projectId: string; updates: UpdateProjectRequest }) => + projectService.updateProject(projectId, updates), + onMutate: async ({ projectId, updates }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: projectKeys.lists() }); + + // Snapshot the previous value + const previousProjects = queryClient.getQueryData(projectKeys.lists()); + + // Optimistically update + queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => { + if (!old) return old; + + // If pinning a project, unpin all others first + if (updates.pinned === true) { + return old.map(p => ({ + ...p, + pinned: p.id === projectId ? true : false + })); + } + + return old.map(p => + p.id === projectId ? { ...p, ...updates } : p + ); + }); + + return { previousProjects }; + }, + onError: (err, variables, context) => { + // Rollback on error + if (context?.previousProjects) { + queryClient.setQueryData(projectKeys.lists(), context.previousProjects); + } + showToast('Failed to update project', 'error'); + }, + onSuccess: (data, variables) => { + // Invalidate and refetch + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + + if (variables.updates.pinned !== undefined) { + const message = variables.updates.pinned + ? `Pinned "${data.title}" as default project` + : `Removed "${data.title}" from default selection`; + showToast(message, 'info'); + } + }, + }); +} + +// Delete project mutation +export function useDeleteProject() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: (projectId: string) => projectService.deleteProject(projectId), + onSuccess: (_, projectId) => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + // Also invalidate the specific project's data + queryClient.removeQueries({ queryKey: projectKeys.detail(projectId) }); + }, + onError: (error) => { + console.error('Failed to delete project:', error); + showToast('Failed to delete project', 'error'); + }, + }); +} + +// Create task mutation +export function useCreateTask() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: (taskData: any) => projectService.createTask(taskData), + onSuccess: (data, variables) => { + // Invalidate tasks for the project + queryClient.invalidateQueries({ queryKey: projectKeys.tasks(variables.project_id) }); + queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + showToast('Task created successfully', 'success'); + }, + onError: (error) => { + console.error('Failed to create task:', error); + showToast('Failed to create task', 'error'); + }, + }); +} + +// Update task mutation with optimistic updates +export function useUpdateTask(projectId: string) { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: ({ taskId, updates }: { taskId: string; updates: any }) => + projectService.updateTask(taskId, updates), + onMutate: async ({ taskId, updates }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: projectKeys.tasks(projectId) }); + + // Snapshot the previous value + const previousTasks = queryClient.getQueryData(projectKeys.tasks(projectId)); + + // Optimistically update + queryClient.setQueryData(projectKeys.tasks(projectId), (old: any[] | undefined) => { + if (!old) return old; + return old.map((task: any) => + task.id === taskId ? { ...task, ...updates } : task + ); + }); + + return { previousTasks }; + }, + onError: (err, variables, context) => { + // Rollback on error + if (context?.previousTasks) { + queryClient.setQueryData(projectKeys.tasks(projectId), context.previousTasks); + } + showToast('Failed to update task', 'error'); + // Refetch on error to ensure consistency + queryClient.invalidateQueries({ queryKey: projectKeys.tasks(projectId) }); + queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + }, + onSuccess: () => { + // Don't refetch on success for task_order updates - trust optimistic update + // Only invalidate task counts + queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + }, + }); +} + +// Delete task mutation +export function useDeleteTask(projectId: string) { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: (taskId: string) => projectService.deleteTask(taskId), + onMutate: async (taskId) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: projectKeys.tasks(projectId) }); + + // Snapshot the previous value + const previousTasks = queryClient.getQueryData(projectKeys.tasks(projectId)); + + // Optimistically remove the task + queryClient.setQueryData(projectKeys.tasks(projectId), (old: any[] | undefined) => { + if (!old) return old; + return old.filter((task: any) => task.id !== taskId); + }); + + return { previousTasks }; + }, + onError: (err, variables, context) => { + // Rollback on error + if (context?.previousTasks) { + queryClient.setQueryData(projectKeys.tasks(projectId), context.previousTasks); + } + showToast('Failed to delete task', 'error'); + }, + onSuccess: () => { + showToast('Task deleted successfully', 'success'); + }, + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ queryKey: projectKeys.tasks(projectId) }); + queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() }); + }, + }); +} \ No newline at end of file diff --git a/archon-ui-main/src/pages/ProjectPage.tsx b/archon-ui-main/src/pages/ProjectPage.tsx index 22091cf3..e979d367 100644 --- a/archon-ui-main/src/pages/ProjectPage.tsx +++ b/archon-ui-main/src/pages/ProjectPage.tsx @@ -1,12 +1,17 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { useToast } from "../contexts/ToastContext"; import { motion } from "framer-motion"; import { useStaggeredEntrance } from "../hooks/useStaggeredEntrance"; -import { useProjectPolling, useTaskPolling } from "../hooks/usePolling"; -import { useDatabaseMutation } from "../hooks/useDatabaseMutation"; -import { useProjectMutation } from "../hooks/useProjectMutation"; -import { debounce } from "../utils/debounce"; +import { + useProjects, + useTaskCounts, + useCreateProject, + useUpdateProject, + useDeleteProject, + projectKeys, +} from "../hooks/useProjectQueries"; import { Tabs, TabsList, @@ -29,13 +34,9 @@ import { Clipboard, } from "lucide-react"; -// Import our service layer and types -import { projectService } from "../services/projectService"; import type { Project, CreateProjectRequest } from "../types/project"; -import type { Task } from "../components/project-tasks/TaskTableView"; import { DeleteConfirmModal } from "../components/common/DeleteConfirmModal"; - interface ProjectPageProps { className?: string; "data-id"?: string; @@ -47,275 +48,56 @@ function ProjectPage({ }: ProjectPageProps) { const { projectId } = useParams(); const navigate = useNavigate(); + const queryClient = useQueryClient(); - // State management for real data + // State management for selected project and UI const [selectedProject, setSelectedProject] = useState(null); - const [tasks, setTasks] = useState([]); - const [projectTaskCounts, setProjectTaskCounts] = useState< - Record - >({}); - const [isLoadingTasks, setIsLoadingTasks] = useState(false); - const [tasksError, setTasksError] = useState(null); - const [isSwitchingProject, setIsSwitchingProject] = useState(false); - - // Task counts cache with 5-minute TTL - const taskCountsCache = useRef<{ - data: Record; - timestamp: number; - } | null>(null); - - // UI state const [activeTab, setActiveTab] = useState("tasks"); const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false); - - // New project form state const [newProjectForm, setNewProjectForm] = useState({ title: "", description: "", }); - - // State for delete confirmation modal const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string; } | null>(null); - - // State for copy feedback const [copiedProjectId, setCopiedProjectId] = useState(null); const { showToast } = useToast(); - // Polling hooks for real-time updates - const { - data: projectsData, - isLoading: isLoadingProjects, - error: projectsError, - refetch: refetchProjects, - } = useProjectPolling({ - onError: (error) => { - console.error("Failed to load projects:", error); - showToast("Failed to load projects. Please try again.", "error"); - }, - }); + // React Query hooks + const { data: projects = [], isLoading: isLoadingProjects, error: projectsError } = useProjects(); + const { data: taskCounts = {}, refetch: refetchTaskCounts } = useTaskCounts(); - // Derive projects array from polling data - ensure it's always an array - const projects = Array.isArray(projectsData) ? projectsData : (projectsData?.projects || []); - - // Poll tasks for selected project - const { - data: tasksData, - isLoading: isPollingTasks, - } = useTaskPolling(selectedProject?.id || "", { - enabled: !!selectedProject && !isSwitchingProject, - onError: (error) => { - console.error("Failed to load tasks:", error); - setTasksError(error.message); - }, - }); + // Mutations + const createProjectMutation = useCreateProject(); + const updateProjectMutation = useUpdateProject(); + const deleteProjectMutation = useDeleteProject(); - // Project mutations - const deleteProjectMutation = useProjectMutation( - null, - async (projectId: string) => { - return await projectService.deleteProject(projectId); - }, - { - successMessage: projectToDelete - ? `Project "${projectToDelete.title}" deleted successfully` - : "Project deleted successfully", - onSuccess: () => { - if (selectedProject?.id === projectToDelete?.id) { - setSelectedProject(null); - // Navigate back to projects without a specific ID - navigate('/projects', { replace: true }); - } - setShowDeleteConfirm(false); - setProjectToDelete(null); - }, - onError: (error) => { - console.error("Failed to delete project:", error); - }, - }, - ); - - const togglePinMutation = useProjectMutation( - null, - async ({ projectId, pinned }: { projectId: string; pinned: boolean }) => { - return await projectService.updateProject(projectId, { pinned }); - }, - { - onSuccess: (data, variables) => { - const message = variables.pinned - ? `Pinned "${data.title}" as default project` - : `Removed "${data.title}" from default selection`; - showToast(message, "info"); - }, - onError: (error) => { - console.error("Failed to update project pin status:", error); - }, - // Disable default success message since we have a custom one - successMessage: '', - }, - ); - - const createProjectMutation = useDatabaseMutation( - async (projectData: CreateProjectRequest) => { - return await projectService.createProject(projectData); - }, - { - successMessage: "Creating project...", - onSuccess: (response) => { - setNewProjectForm({ title: "", description: "" }); - setIsNewProjectModalOpen(false); - // Polling will pick up the new project - showToast("Project created successfully!", "success"); - refetchProjects(); - }, - onError: (error) => { - console.error("Failed to create project:", error); - }, - }, - ); - - // Direct API call for immediate task loading during project switch - const loadTasksForProject = useCallback(async (projectId: string) => { - try { - const taskData = await projectService.getTasksByProject(projectId); - - // Use the same formatting logic as polling onSuccess callback - const uiTasks: Task[] = taskData.map((task: any) => ({ - id: task.id, - title: task.title, - description: task.description, - status: (task.status || "todo") as Task["status"], - assignee: { - name: (task.assignee || "User") as - | "User" - | "Archon" - | "AI IDE Agent", - avatar: "", - }, - feature: task.feature || "General", - featureColor: task.featureColor || "#6366f1", - task_order: task.task_order || 0, - })); - - setTasks(uiTasks); - } catch (error) { - console.error("Failed to load tasks:", error); - setTasksError( - error instanceof Error ? error.message : "Failed to load tasks", - ); - } - }, []); - - const handleProjectSelect = useCallback(async (project: Project) => { - // Early return if already selected - if (selectedProject?.id === project.id) return; - - // Show loading state during project switch - setIsSwitchingProject(true); - setTasksError(null); - setTasks([]); // Clear stale tasks immediately to prevent wrong data showing - - try { - setSelectedProject(project); - setActiveTab("tasks"); - - // Update URL to reflect selected project - navigate(`/projects/${project.id}`, { replace: true }); - - // Load tasks for the new project - await loadTasksForProject(project.id); - } catch (error) { - console.error('Failed to switch project:', error); - showToast('Failed to load project tasks', 'error'); - } finally { - setIsSwitchingProject(false); - } - }, [selectedProject?.id, loadTasksForProject, showToast, navigate]); - - // Load task counts for all projects using batch endpoint - const loadTaskCountsForAllProjects = useCallback( - async (projectIds: string[], force = false) => { - // Check cache first (5-minute TTL = 300000ms) unless force refresh is requested - const now = Date.now(); - if (!force && taskCountsCache.current && - (now - taskCountsCache.current.timestamp) < 300000) { - // Use cached data - const cachedCounts = taskCountsCache.current.data; - const filteredCounts: Record = {}; - projectIds.forEach((projectId) => { - if (cachedCounts[projectId]) { - filteredCounts[projectId] = cachedCounts[projectId]; - } else { - filteredCounts[projectId] = { todo: 0, doing: 0, done: 0 }; - } - }); - setProjectTaskCounts(filteredCounts); - return; - } - - try { - // Use single batch API call instead of N parallel calls - const counts = await projectService.getTaskCountsForAllProjects(); - - // Update cache - taskCountsCache.current = { - data: counts, - timestamp: now - }; - - // Filter to only requested projects and provide defaults for missing ones - const filteredCounts: Record = {}; - projectIds.forEach((projectId) => { - if (counts[projectId]) { - filteredCounts[projectId] = counts[projectId]; - } else { - // Provide default counts if project not found - filteredCounts[projectId] = { todo: 0, doing: 0, done: 0 }; - } - }); - - setProjectTaskCounts(filteredCounts); - } catch (error) { - console.error("Failed to load task counts:", error); - // Set all to 0 on complete failure - const emptyCounts: Record = {}; - projectIds.forEach((id) => { - emptyCounts[id] = { todo: 0, doing: 0, done: 0 }; - }); - setProjectTaskCounts(emptyCounts); - } - }, - [], - ); - - // Create debounced version to avoid rapid API calls - const debouncedLoadTaskCounts = useMemo( - () => debounce((projectIds: string[], force = false) => { - loadTaskCountsForAllProjects(projectIds, force); - }, 1000), - [loadTaskCountsForAllProjects] - ); - - // Auto-select project based on URL or default to leftmost - useEffect(() => { - if (!projects?.length) return; - - // Sort projects - single pinned project first, then alphabetically - const sortedProjects = [...projects].sort((a, b) => { - // With single pin, this is simpler: pinned project always comes first + // Sort projects - pinned first, then alphabetically + const sortedProjects = useMemo(() => { + return [...projects].sort((a, b) => { if (a.pinned) return -1; if (b.pinned) return 1; return a.title.localeCompare(b.title); }); + }, [projects]); - // Load task counts for all projects (debounced, no force since this is initial load) - const projectIds = sortedProjects.map((p) => p.id); - debouncedLoadTaskCounts(projectIds, false); + // Handle project selection + const handleProjectSelect = useCallback((project: Project) => { + if (selectedProject?.id === project.id) return; + + setSelectedProject(project); + setActiveTab("tasks"); + navigate(`/projects/${project.id}`, { replace: true }); + }, [selectedProject?.id, navigate]); + + // Auto-select project based on URL or default to leftmost + useEffect(() => { + if (!sortedProjects.length) return; // If we have a projectId in the URL, try to select that project if (projectId) { @@ -324,70 +106,22 @@ function ProjectPage({ handleProjectSelect(urlProject); return; } - // If URL project not found, fall through to default selection } // Select the leftmost (first) project if none is selected if (!selectedProject && sortedProjects.length > 0) { - const leftmostProject = sortedProjects[0]; - handleProjectSelect(leftmostProject); + handleProjectSelect(sortedProjects[0]); } - }, [projects, selectedProject, handleProjectSelect, projectId]); + }, [sortedProjects, projectId, selectedProject, handleProjectSelect]); - // Update loading state based on polling + // Refetch task counts when project changes useEffect(() => { - setIsLoadingTasks(isPollingTasks); - }, [isPollingTasks]); - - // Refresh task counts when tasks update via polling AND keep UI in sync for selected project - useEffect(() => { - if (tasksData && selectedProject) { - const uiTasks: Task[] = tasksData.map((task: any) => ({ - id: task.id, - title: task.title, - description: task.description, - status: (task.status || "todo") as Task["status"], - assignee: { - name: (task.assignee || "User") as "User" | "Archon" | "AI IDE Agent", - avatar: "", - }, - feature: task.feature || "General", - featureColor: task.featureColor || "#6366f1", - task_order: task.task_order || 0, - })); - - const changed = - tasks.length !== uiTasks.length || - uiTasks.some((t) => { - const old = tasks.find((x) => x.id === t.id); - return ( - !old || - old.title !== t.title || - old.description !== t.description || - old.status !== t.status || - old.assignee.name !== t.assignee.name || - old.feature !== t.feature || - old.task_order !== t.task_order - ); - }); - if (changed) { - setTasks(uiTasks); - const projectIds = projects.map((p) => p.id); - debouncedLoadTaskCounts(projectIds, true); - } + if (selectedProject) { + refetchTaskCounts(); } - }, [tasksData, projects, selectedProject?.id]); - - // Manual refresh function using polling refetch - const loadProjects = async () => { - try { - await refetchProjects(); - } catch (error) { - console.error("Failed to refresh projects:", error); - showToast("Failed to refresh projects. Please try again.", "error"); - } - }; + }, [selectedProject?.id, refetchTaskCounts]); + // Handle project operations const handleDeleteProject = useCallback( async (e: React.MouseEvent, projectId: string, projectTitle: string) => { e.stopPropagation(); @@ -402,10 +136,20 @@ function ProjectPage({ try { await deleteProjectMutation.mutateAsync(projectToDelete.id); + + if (selectedProject?.id === projectToDelete.id) { + setSelectedProject(null); + navigate('/projects', { replace: true }); + } + + showToast(`Project "${projectToDelete.title}" deleted successfully`, 'success'); } catch (error) { - // Error handling is done by the mutation + // Error handled by mutation + } finally { + setShowDeleteConfirm(false); + setProjectToDelete(null); } - }, [projectToDelete, deleteProjectMutation]); + }, [projectToDelete, deleteProjectMutation, selectedProject?.id, navigate, showToast]); const cancelDeleteProject = useCallback(() => { setShowDeleteConfirm(false); @@ -416,27 +160,16 @@ function ProjectPage({ async (e: React.MouseEvent, project: Project) => { e.stopPropagation(); - const isPinning = !project.pinned; - try { - // Backend handles single-pin enforcement automatically - await togglePinMutation.mutateAsync({ + await updateProjectMutation.mutateAsync({ projectId: project.id, - pinned: isPinning, + updates: { pinned: !project.pinned }, }); - - // Force immediate refresh of projects to update UI positioning - // This ensures the pinned project moves to leftmost position immediately - refetchProjects(); - } catch (error) { - console.error("Failed to toggle pin:", error); - showToast("Failed to update pin status", "error"); - // On error, still refresh to ensure UI is consistent with backend - refetchProjects(); + // Error handled by mutation } }, - [togglePinMutation, showToast, refetchProjects], + [updateProjectMutation], ); const handleCreateProject = async () => { @@ -452,14 +185,20 @@ function ProjectPage({ data: [], }; - await createProjectMutation.mutateAsync(projectData); + try { + await createProjectMutation.mutateAsync(projectData); + setNewProjectForm({ title: "", description: "" }); + setIsNewProjectModalOpen(false); + } catch (error) { + // Error handled by mutation + } }; + // Add staggered entrance animations const { isVisible, containerVariants, itemVariants, titleVariants } = useStaggeredEntrance([1, 2, 3], 0.15); - return (

- {projectsError.message || "Failed to load projects"} + {(projectsError as Error).message || "Failed to load projects"}

+
+ + + Doing + +
+
+ + {taskCounts[project.id]?.doing || 0} + +
+ + - {/* Copy Project ID Button */} - - - {/* Delete button */} - +
+ + + Done + +
+
+ + {taskCounts[project.id]?.done || 0} + +
+ -
+ + {/* Action buttons */} +
+ {/* Pin button */} + + + {/* Copy Project ID Button */} + + + {/* Delete button */} + +
+ + ))} @@ -780,20 +518,6 @@ function ProjectPage({ {/* Project Details Section */} {selectedProject && ( - {/* Loading overlay when switching projects */} - {isSwitchingProject && ( -
-
-
-
- - Loading project... - -
-
-
- )} - - {/* Tab content without AnimatePresence to prevent unmounting */} + {/* Tab content */}
{activeTab === "docs" && ( - + )} {activeTab === "tasks" && ( - {isLoadingTasks ? ( -
-
- -

- Loading tasks... -

-
-
- ) : tasksError ? ( -
-
- -

- {tasksError} -

- -
-
- ) : ( - { - setTasks(updatedTasks); - // Force refresh task counts for all projects when tasks change - const projectIds = projects.map((p) => p.id); - debouncedLoadTaskCounts(projectIds, true); - }} - projectId={selectedProject.id} - /> - )} +
)}
@@ -984,4 +670,4 @@ function ProjectPage({ ); } -export { ProjectPage }; +export { ProjectPage }; \ No newline at end of file diff --git a/archon-ui-main/src/services/projectService.ts b/archon-ui-main/src/services/projectService.ts index 349986e6..b7e92cd2 100644 --- a/archon-ui-main/src/services/projectService.ts +++ b/archon-ui-main/src/services/projectService.ts @@ -131,9 +131,11 @@ export const projectService = { async listProjects(): Promise { try { console.log('[PROJECT SERVICE] Fetching projects from API'); - const projects = await callAPI('/api/projects'); - console.log('[PROJECT SERVICE] Raw API response:', projects); - console.log('[PROJECT SERVICE] Raw API response length:', projects.length); + const response = await callAPI<{ projects: Project[] }>('/api/projects'); + console.log('[PROJECT SERVICE] Raw API response:', response); + + const projects = response.projects || []; + console.log('[PROJECT SERVICE] Projects array length:', projects.length); // Debug raw pinned values projects.forEach((p: any) => { diff --git a/docker-compose.yml b/docker-compose.yml index 3d9a495b..f15be92e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,7 @@ services: - HOST=${HOST:-localhost} - PROD=${PROD:-false} - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-} + - VITE_SHOW_DEVTOOLS=${VITE_SHOW_DEVTOOLS:-false} networks: - app-network healthcheck: diff --git a/report.md b/report.md new file mode 100644 index 00000000..e62fd37e --- /dev/null +++ b/report.md @@ -0,0 +1,297 @@ +# Archon Data Fetching Architecture Analysis + +## Executive Summary + +After conducting a deep analysis of Archon's current data fetching implementation, I've found a **mixed architecture** where some components have been refactored to use TanStack Query while others still use traditional polling. The backend has a sophisticated HTTP polling system with ETag optimization. This report analyzes whether continuing with TanStack Query is the right path forward. + +**Key Findings:** +- ✅ **TanStack Query is the right choice** for most use cases +- ✅ Backend HTTP polling with ETags is well-architected and performant +- âš ī¸ **Inconsistent implementation** - mixed patterns causing confusion +- ❌ WebSocket would add complexity without significant benefits for current use cases + +## Current Architecture Analysis + +### Backend: HTTP Polling with ETag Optimization + +The backend implements a sophisticated polling system: + +**Progress API (`/api/progress/{operation_id}`):** +```python +# ETag support for 70% bandwidth reduction via 304 Not Modified +current_etag = generate_etag(etag_data) +if check_etag(if_none_match, current_etag): + response.status_code = http_status.HTTP_304_NOT_MODIFIED + return None + +# Smart polling hints +if operation.get("status") == "running": + response.headers["X-Poll-Interval"] = "1000" # Poll every 1s +else: + response.headers["X-Poll-Interval"] = "0" # Stop polling +``` + +**ProgressTracker (In-Memory State):** +- Thread-safe class-level storage: `_progress_states: dict[str, dict[str, Any]]` +- Prevents progress regression: Never allows backwards progress updates +- Automatic cleanup and duration calculation +- Rich status tracking with logs and metadata + +**ETag Implementation:** +- MD5 hash of stable JSON data (excluding timestamps) +- 304 Not Modified responses when data unchanged +- ~70% bandwidth reduction in practice + +### Frontend: Mixed Implementation Patterns + +#### ✅ **TanStack Query Implementation** (New Components) + +**Query Key Factories:** +```typescript +export const projectKeys = { + all: ['projects'] as const, + lists: () => [...projectKeys.all, 'list'] as const, + detail: (id: string) => [...projectKeys.details(), id] as const, + tasks: (projectId: string) => [...projectKeys.detail(projectId), 'tasks'] as const, +}; +``` + +**Optimistic Updates:** +```typescript +onMutate: async ({ taskId, updates }) => { + await queryClient.cancelQueries({ queryKey: projectKeys.tasks(projectId) }); + const previousTasks = queryClient.getQueryData(projectKeys.tasks(projectId)); + + queryClient.setQueryData(projectKeys.tasks(projectId), (old: any[]) => { + return old.map((task: any) => + task.id === taskId ? { ...task, ...updates } : task + ); + }); + + return { previousTasks }; +}, +``` + +**Progress Polling with Smart Completion:** +```typescript +export function useCrawlProgressPolling(progressId: string | null) { + const [isComplete, setIsComplete] = useState(false); + + const query = useQuery({ + queryKey: crawlKeys.progress(progressId!), + queryFn: async () => { + const response = await fetch(`/api/progress/${progressId}`); + return response.json(); + }, + enabled: !!progressId && !isComplete, + refetchInterval: 1000, // 1 second polling + retry: false, + staleTime: 0, + }); + + // Auto-stop polling when complete + useEffect(() => { + const status = query.data?.status; + if (['completed', 'failed', 'error', 'cancelled'].includes(status)) { + setIsComplete(true); + } + }, [query.data?.status]); + + return { ...query, isComplete }; +} +``` + +#### ❌ **Legacy Implementation** (KnowledgeBasePage) + +Still uses manual useState with custom polling: +```typescript +const [knowledgeItems, setKnowledgeItems] = useState([]); +const [loading, setLoading] = useState(true); +const [progressItems, setProgressItems] = useState([]); + +// Manual API calls +const loadKnowledgeItems = async () => { + try { + setLoading(true); + const response = await knowledgeBaseService.getKnowledgeItems(); + setKnowledgeItems(response.items); + } catch (error) { + // Manual error handling + } finally { + setLoading(false); + } +}; +``` + +#### âš ī¸ **Remaining Traditional Hooks** + +`useMigrationStatus.ts` - Uses setInterval polling: +```typescript +useEffect(() => { + const checkMigrationStatus = async () => { + const response = await fetch('/api/health'); + // Manual state updates + }; + + const interval = setInterval(checkMigrationStatus, 30000); + return () => clearInterval(interval); +}, []); +``` + +## TanStack Query vs WebSocket Analysis + +### TanStack Query Advantages ✅ + +1. **Perfect for Archon's Use Cases:** + - CRUD operations on projects, tasks, knowledge items + - Progress polling with natural start/stop lifecycle + - Background refetching for stale data + - Optimistic updates for immediate UI feedback + +2. **Built-in Features:** + - Automatic background refetching + - Request deduplication + - Error retry with exponential backoff + - Cache invalidation strategies + - Loading and error states + - Optimistic updates with rollback + +3. **Performance Benefits:** + - Client-side caching reduces server load + - ETags work perfectly with query invalidation + - Smart refetch intervals (active/background) + - Automatic garbage collection + +4. **Developer Experience:** + - Declarative data dependencies + - Less boilerplate than manual useState + - Excellent DevTools for debugging + - Type-safe with TypeScript + +### WebSocket Analysis ❌ + +**Current Use Cases Don't Need Real-time:** +- Progress updates: 1-2 second delay acceptable +- Project/task updates: Not truly collaborative +- Knowledge base changes: Batch-oriented operations + +**WebSocket Downsides:** +- Connection management complexity +- Reconnection logic needed +- Scaling challenges (sticky sessions) +- No HTTP caching benefits +- Additional security considerations +- Browser connection limits (6 per domain) + +**When WebSockets Make Sense:** +- Real-time collaboration (multiple users editing same document) +- Live chat/notifications +- Live data feeds (stock prices, sports scores) +- Gaming applications + +### Performance Comparison + +| Metric | HTTP Polling + TanStack | WebSocket | +|--------|-------------------------|-----------| +| Initial Connection | HTTP request (~10-50ms) | WebSocket handshake (~100-200ms) | +| Update Latency | 500-2000ms (configurable) | ~10-100ms | +| Bandwidth (unchanged data) | ~100 bytes (304 response) | ~50 bytes (heartbeat) | +| Bandwidth (changed data) | Full payload + headers | Full payload | +| Server Memory | Stateless (per request) | Connection state per client | +| Horizontal Scaling | Easy (stateless) | Complex (sticky sessions) | +| Browser Limits | ~6 concurrent per domain | ~255 concurrent total | +| Error Recovery | Automatic retry | Manual reconnection logic | + +## Current Issues & Recommendations + +### 🔴 **Critical Issues** + +1. **Inconsistent Patterns:** Mix of TanStack Query, manual useState, and setInterval polling +2. **KnowledgeBasePage Not Migrated:** Still using 795 lines of manual state management +3. **Prop Drilling:** Components receiving 5+ callback props instead of using mutations + +### 🟡 **Performance Issues** + +1. **Multiple Polling Intervals:** Different components polling at different rates +2. **No Request Deduplication:** Manual implementations don't dedupe requests +3. **Cache Misses:** Manual state doesn't benefit from cross-component caching + +### ✅ **Recommended Solution: Complete TanStack Query Migration** + +#### Phase 1: Complete Current Migration +```typescript +// Migrate KnowledgeBasePage to use: +const { data: knowledgeItems, isLoading, error } = useKnowledgeItems(); +const { data: progressItems, addProgressItem, removeProgressItem } = useCrawlProgressManager(); +const deleteMutation = useDeleteKnowledgeItem(); +``` + +#### Phase 2: Optimize Query Configuration +```typescript +// Global query client optimization +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // 30s for most data + gcTime: 5 * 60_000, // 5min cache retention + retry: (failureCount, error) => { + // Smart retry logic + if (error.status === 404) return false; + return failureCount < 3; + }, + }, + }, +}); +``` + +#### Phase 3: Advanced Patterns +```typescript +// Progress polling with exponential backoff +const useSmartProgressPolling = (progressId: string) => { + const [pollInterval, setPollInterval] = useState(1000); + + return useQuery({ + queryKey: ['progress', progressId], + queryFn: () => fetchProgress(progressId), + refetchInterval: (data) => { + if (data?.status === 'completed') return false; + + // Exponential backoff for long-running operations + const runtime = Date.now() - data?.start_time; + if (runtime > 60_000) return 5000; // 5s after 1 minute + if (runtime > 300_000) return 10_000; // 10s after 5 minutes + return 1000; // 1s for first minute + }, + }); +}; +``` + +### đŸŽ¯ **Migration Strategy** + +1. **Keep Backend As-Is:** HTTP polling + ETags is working well +2. **Complete TanStack Migration:** Migrate remaining components +3. **Standardize Query Keys:** Consistent factory pattern +4. **Optimize Poll Intervals:** Smart intervals based on data type +5. **Add Error Boundaries:** Better error handling at app level + +### 🚀 **Expected Benefits** + +- **50% Less Component Code:** Remove manual useState boilerplate +- **Better UX:** Optimistic updates, background refetching, error retry +- **Improved Performance:** Request deduplication, smart caching +- **Easier Debugging:** TanStack DevTools visibility +- **Type Safety:** Better TypeScript integration + +## Conclusion + +**✅ Continue with TanStack Query migration** - it's the right architectural choice for Archon's use cases. The backend HTTP polling system is well-designed and doesn't need changes. Focus on: + +1. **Completing the migration** of remaining components +2. **Standardizing patterns** across all data fetching +3. **Optimizing query configurations** for better performance + +WebSocket would add complexity without meaningful benefits for current requirements. The HTTP polling + TanStack Query combination provides the right balance of performance, developer experience, and maintainability. + +--- +*Analysis completed on 2025-01-03* +*Total files analyzed: 15+ backend files, 9 frontend hooks, 5 major components* \ No newline at end of file