From 1b5196d70f335a1498a206b8d1c2151fb8cf4495 Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Wed, 20 Aug 2025 02:28:02 -0400 Subject: [PATCH 01/12] - Fix the threading service to properly handle rate limiting. - Fix the clipboard functionality to work on non local hosts and https - Improvements in sockets on front-end and backend. Storing session in local browser storage for reconnect. Logic to prevent socket echos coausing rerender and performance issues. - Fixes and udpates to re-ordering logic in adding a new task, reordering items on the task table. - Allowing assignee to not be hardcoded enum. - Fix to Document Version Control (Improvements still needed in the Milkdown editor conversion to store in the docs. - Adding types to remove [any] typescript issues. --- archon-ui-main/package-lock.json | 21 + archon-ui-main/package.json | 2 + .../src/components/ErrorBoundary.tsx | 319 +++++++++++++++ .../src/components/SearchableList.tsx | 365 ++++++++++++++++++ .../knowledge-base/KnowledgeItemCard.tsx | 2 +- .../src/components/project-tasks/DocsTab.tsx | 48 +-- .../components/project-tasks/DocumentCard.tsx | 27 +- .../project-tasks/DraggableTaskCard.tsx | 26 +- .../project-tasks/EditTaskModal.tsx | 2 +- .../components/project-tasks/FeaturesTab.tsx | 2 +- .../project-tasks/TaskInputComponents.tsx | 2 +- .../project-tasks/TaskTableView.tsx | 78 ++-- .../src/components/project-tasks/TasksTab.tsx | 177 +++++---- .../project-tasks/VersionHistoryModal.tsx | 2 +- .../components/MarkdownDocumentRenderer.tsx | 2 +- .../src/components/prp/types/prp.types.ts | 2 +- archon-ui-main/src/pages/ProjectPage.tsx | 25 +- archon-ui-main/src/pages/SettingsPage.tsx | 58 ++- archon-ui-main/src/schemas/project.schemas.ts | 213 ++++++++++ .../src/services/crawlProgressService.ts | 26 +- .../src/services/socketIOService.ts | 255 ++++++++++-- archon-ui-main/src/types/document.ts | 185 +++++++++ archon-ui-main/src/types/jsonb.ts | 81 ++++ archon-ui-main/src/types/project.ts | 34 +- archon-ui-main/src/utils/clipboard.ts | 66 ++++ archon-ui-main/src/utils/logger.ts | 91 +++++ archon-ui-main/src/utils/operationTracker.ts | 283 ++++++++++++++ archon-ui-main/src/utils/typeGuards.ts | 252 ++++++++++++ .../test/components/ErrorBoundary.test.tsx | 176 +++++++++ .../components/layouts/MainLayout.test.tsx | 163 ++++++++ .../project-tasks/TasksTab.test.tsx | 288 ++++++++++++++ .../test/services/socketIOService.test.ts | 195 ++++++++++ .../test/utils/operationTracker.test.ts | 238 ++++++++++++ .../src/server/api_routes/agent_chat_api.py | 4 +- python/src/server/api_routes/knowledge_api.py | 4 +- .../server/api_routes/socketio_broadcasts.py | 4 +- .../server/api_routes/socketio_handlers.py | 5 +- .../knowledge/knowledge_item_service.py | 8 +- .../services/projects/progress_service.py | 5 +- .../server/services/projects/task_service.py | 4 +- .../services/storage/code_storage_service.py | 7 +- .../storage/document_storage_service.py | 12 +- .../src/server/services/threading_service.py | 61 +-- python/src/server/socketio_app.py | 1 - 44 files changed, 3490 insertions(+), 331 deletions(-) create mode 100644 archon-ui-main/src/components/ErrorBoundary.tsx create mode 100644 archon-ui-main/src/components/SearchableList.tsx create mode 100644 archon-ui-main/src/schemas/project.schemas.ts create mode 100644 archon-ui-main/src/types/document.ts create mode 100644 archon-ui-main/src/types/jsonb.ts create mode 100644 archon-ui-main/src/utils/clipboard.ts create mode 100644 archon-ui-main/src/utils/logger.ts create mode 100644 archon-ui-main/src/utils/operationTracker.ts create mode 100644 archon-ui-main/src/utils/typeGuards.ts create mode 100644 archon-ui-main/test/components/ErrorBoundary.test.tsx create mode 100644 archon-ui-main/test/components/layouts/MainLayout.test.tsx create mode 100644 archon-ui-main/test/components/project-tasks/TasksTab.test.tsx create mode 100644 archon-ui-main/test/services/socketIOService.test.ts create mode 100644 archon-ui-main/test/utils/operationTracker.test.ts diff --git a/archon-ui-main/package-lock.json b/archon-ui-main/package-lock.json index 831b1a92..c32001e0 100644 --- a/archon-ui-main/package-lock.json +++ b/archon-ui-main/package-lock.json @@ -12,6 +12,7 @@ "@milkdown/kit": "^7.5.0", "@milkdown/plugin-history": "^7.5.0", "@milkdown/preset-commonmark": "^7.5.0", + "@types/uuid": "^10.0.0", "@xyflow/react": "^12.3.0", "clsx": "latest", "date-fns": "^4.1.0", @@ -26,6 +27,7 @@ "react-router-dom": "^6.26.2", "socket.io-client": "^4.8.1", "tailwind-merge": "latest", + "uuid": "^11.1.0", "zod": "^3.25.46" }, "devDependencies": { @@ -2977,6 +2979,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -10025,6 +10033,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/archon-ui-main/package.json b/archon-ui-main/package.json index fc6a1d1a..70fa7136 100644 --- a/archon-ui-main/package.json +++ b/archon-ui-main/package.json @@ -22,6 +22,7 @@ "@milkdown/kit": "^7.5.0", "@milkdown/plugin-history": "^7.5.0", "@milkdown/preset-commonmark": "^7.5.0", + "@types/uuid": "^10.0.0", "@xyflow/react": "^12.3.0", "clsx": "latest", "date-fns": "^4.1.0", @@ -36,6 +37,7 @@ "react-router-dom": "^6.26.2", "socket.io-client": "^4.8.1", "tailwind-merge": "latest", + "uuid": "^11.1.0", "zod": "^3.25.46" }, "devDependencies": { diff --git a/archon-ui-main/src/components/ErrorBoundary.tsx b/archon-ui-main/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..81f2337e --- /dev/null +++ b/archon-ui-main/src/components/ErrorBoundary.tsx @@ -0,0 +1,319 @@ +/** + * Error Boundary Component with React 18 Features + * Provides fallback UI and error recovery options + */ + +import React, { Component, ErrorInfo, ReactNode, Suspense } from 'react'; +import { AlertTriangle, RefreshCw, Home } from 'lucide-react'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + errorCount: number; +} + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: (error: Error, errorInfo: ErrorInfo, reset: () => void) => ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + resetKeys?: Array; + resetOnPropsChange?: boolean; + isolate?: boolean; + level?: 'page' | 'section' | 'component'; +} + +/** + * Enhanced Error Boundary with recovery options + */ +export class ErrorBoundary extends Component { + private resetTimeoutId: NodeJS.Timeout | null = null; + private previousResetKeys: Array = []; + + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + errorCount: 0 + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + const { onError } = this.props; + + // Log error details + console.error('Error caught by boundary:', error); + console.error('Error info:', errorInfo); + + // Update state with error details + this.setState(prevState => ({ + errorInfo, + errorCount: prevState.errorCount + 1 + })); + + // Call error handler if provided + if (onError) { + onError(error, errorInfo); + } + + // In alpha, we want to fail fast and require explicit user action + // Log detailed error information for debugging + console.error('[ErrorBoundary] Component error caught:', { + error: error.toString(), + stack: error.stack, + componentStack: errorInfo.componentStack, + errorCount: this.state.errorCount + 1, + isolate: this.props.isolate + }); + } + + componentDidUpdate(prevProps: ErrorBoundaryProps): void { + const { resetKeys, resetOnPropsChange } = this.props; + const { hasError } = this.state; + + // Reset on prop changes if enabled + if (hasError && prevProps.children !== this.props.children && resetOnPropsChange) { + this.reset(); + } + + // Reset on resetKeys change + if (hasError && resetKeys && this.previousResetKeys !== resetKeys) { + const hasResetKeyChanged = resetKeys.some( + (key, index) => key !== this.previousResetKeys[index] + ); + + if (hasResetKeyChanged) { + this.reset(); + } + } + + this.previousResetKeys = resetKeys || []; + } + + componentWillUnmount(): void { + if (this.resetTimeoutId) { + clearTimeout(this.resetTimeoutId); + this.resetTimeoutId = null; + } + } + + reset = (): void => { + if (this.resetTimeoutId) { + clearTimeout(this.resetTimeoutId); + this.resetTimeoutId = null; + } + + this.setState({ + hasError: false, + error: null, + errorInfo: null + }); + }; + + render(): ReactNode { + const { hasError, error, errorInfo, errorCount } = this.state; + const { children, fallback, level = 'component' } = this.props; + + if (hasError && error && errorInfo) { + // Use custom fallback if provided + if (fallback) { + return fallback(error, errorInfo, this.reset); + } + + // Default fallback UI based on level + return ; + } + + return children; + } +} + +/** + * Default error fallback component + */ +interface DefaultErrorFallbackProps { + error: Error; + errorInfo: ErrorInfo; + reset: () => void; + level: 'page' | 'section' | 'component'; + errorCount: number; +} + +const DefaultErrorFallback: React.FC = ({ + error, + errorInfo, + reset, + level, + errorCount +}) => { + const isPageLevel = level === 'page'; + const isSectionLevel = level === 'section'; + + if (level === 'component') { + // Minimal component-level error + return ( +
+
+ + + Component error occurred + + +
+
+ ); + } + + return ( +
+
+
+ {/* Error Icon */} +
+
+ +
+
+ + {/* Error Title */} +

+ {isPageLevel ? 'Something went wrong' : 'An error occurred'} +

+ + {/* Error Message */} +

+ {error.message || 'An unexpected error occurred while rendering this component.'} +

+ + {/* Retry Count */} + {errorCount > 1 && ( +

+ This error has occurred {errorCount} times +

+ )} + + {/* Action Buttons */} +
+ + + {isPageLevel && ( + + )} +
+ + {/* Error Details (Development Only) */} + {process.env.NODE_ENV === 'development' && ( +
+ + Error Details (Development Only) + +
+
+

+ {error.stack} +

+
+
+

+ Component Stack: +

+

+ {errorInfo.componentStack} +

+
+
+
+ )} +
+
+
+ ); +}; + +/** + * Suspense Error Boundary - combines Suspense with Error Boundary + */ +interface SuspenseErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + errorFallback?: (error: Error, errorInfo: ErrorInfo, reset: () => void) => ReactNode; + level?: 'page' | 'section' | 'component'; +} + +export const SuspenseErrorBoundary: React.FC = ({ + children, + fallback, + errorFallback, + level = 'component' +}) => { + const defaultFallback = ( +
+
+
+ ); + + return ( + + + {children} + + + ); +}; + +/** + * Hook to reset error boundaries + */ +export function useErrorHandler(): (error: Error) => void { + return (error: Error) => { + throw error; + }; +} \ No newline at end of file diff --git a/archon-ui-main/src/components/SearchableList.tsx b/archon-ui-main/src/components/SearchableList.tsx new file mode 100644 index 00000000..9c3b370d --- /dev/null +++ b/archon-ui-main/src/components/SearchableList.tsx @@ -0,0 +1,365 @@ +/** + * SearchableList Component with React 18 Concurrent Features + * Uses useTransition for non-blocking search updates + */ + +import React, { useState, useTransition, useMemo, useCallback } from 'react'; +import { Search, X, Loader2 } from 'lucide-react'; + +export interface SearchableListItem { + id: string; + title: string; + description?: string; + metadata?: Record; +} + +export interface SearchableListProps { + items: T[]; + onItemClick?: (item: T) => void; + onItemSelect?: (item: T) => void; + renderItem?: (item: T, isHighlighted: boolean) => React.ReactNode; + searchFields?: (keyof T)[]; + placeholder?: string; + emptyMessage?: string; + className?: string; + itemClassName?: string; + enableMultiSelect?: boolean; + selectedItems?: T[]; + virtualize?: boolean; + virtualizeThreshold?: number; + // Virtualization configuration + itemHeight?: number; // Height of each item in pixels (default: 80) + containerHeight?: number; // Height of scrollable container in pixels (default: 600) +} + +/** + * SearchableList with React 18 concurrent features + */ +export function SearchableList({ + items, + onItemClick, + onItemSelect, + renderItem, + searchFields = ['title', 'description'] as (keyof T)[], + placeholder = 'Search...', + emptyMessage = 'No items found', + className = '', + itemClassName = '', + enableMultiSelect = false, + selectedItems = [], + virtualize = true, + virtualizeThreshold = 100, + itemHeight = 80, + containerHeight = 600 +}: SearchableListProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [highlightedId, setHighlightedId] = useState(null); + const [selectedIds, setSelectedIds] = useState>( + new Set(selectedItems.map(item => item.id)) + ); + + // Use transition for non-blocking search updates + const [isPending, startTransition] = useTransition(); + + /** + * Filter items based on search query with transition + */ + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) { + return items; + } + + const query = searchQuery.toLowerCase(); + return items.filter(item => { + return searchFields.some(field => { + const value = item[field]; + if (typeof value === 'string') { + return value.toLowerCase().includes(query); + } + if (value && typeof value === 'object') { + return JSON.stringify(value).toLowerCase().includes(query); + } + return false; + }); + }); + }, [items, searchQuery, searchFields]); + + /** + * Handle search input with transition + */ + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + // Use transition for non-urgent update + startTransition(() => { + setSearchQuery(value); + }); + }, []); + + /** + * Clear search + */ + const handleClearSearch = useCallback(() => { + startTransition(() => { + setSearchQuery(''); + }); + }, []); + + /** + * Handle item selection + */ + const handleItemSelect = useCallback((item: T) => { + if (enableMultiSelect) { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(item.id)) { + next.delete(item.id); + } else { + next.add(item.id); + } + return next; + }); + } else { + setSelectedIds(new Set([item.id])); + } + + if (onItemSelect) { + onItemSelect(item); + } + }, [enableMultiSelect, onItemSelect]); + + /** + * Handle item click + */ + const handleItemClick = useCallback((item: T) => { + if (onItemClick) { + onItemClick(item); + } else { + handleItemSelect(item); + } + }, [onItemClick, handleItemSelect]); + + /** + * Default item renderer + */ + const defaultRenderItem = useCallback((item: T, isHighlighted: boolean) => { + const isSelected = selectedIds.has(item.id); + + return ( +
setHighlightedId(item.id)} + onMouseLeave={() => setHighlightedId(null)} + onClick={() => handleItemClick(item)} + > +
+
+

+ {item.title} +

+ {item.description && ( +

+ {item.description} +

+ )} +
+ {enableMultiSelect && ( + handleItemSelect(item)} + onClick={(e) => e.stopPropagation()} + className="ml-3 mt-1 h-4 w-4 text-blue-600 rounded focus:ring-blue-500" + /> + )} +
+
+ ); + }, [selectedIds, itemClassName, handleItemClick, handleItemSelect, enableMultiSelect]); + + /** + * Virtualized list renderer for large lists + */ + const [scrollTop, setScrollTop] = useState(0); + + const renderVirtualizedList = useCallback(() => { + // Simple virtualization with configurable dimensions + const visibleCount = Math.ceil(containerHeight / itemHeight); + + const startIndex = Math.floor(scrollTop / itemHeight); + const endIndex = Math.min(startIndex + visibleCount + 1, filteredItems.length); + const visibleItems = filteredItems.slice(startIndex, endIndex); + const totalHeight = filteredItems.length * itemHeight; + const offsetY = startIndex * itemHeight; + + return ( +
setScrollTop(e.currentTarget.scrollTop)} + > +
+
+ {visibleItems.map(item => ( +
+ {renderItem ? renderItem(item, highlightedId === item.id) : defaultRenderItem(item, highlightedId === item.id)} +
+ ))} +
+
+
+ ); + }, [filteredItems, highlightedId, renderItem, defaultRenderItem, containerHeight, itemHeight, scrollTop]); + + /** + * Regular list renderer + */ + const renderRegularList = useCallback(() => { + return ( +
+ {filteredItems.map(item => ( +
+ {renderItem ? renderItem(item, highlightedId === item.id) : defaultRenderItem(item, highlightedId === item.id)} +
+ ))} +
+ ); + }, [filteredItems, highlightedId, renderItem, defaultRenderItem]); + + return ( +
+ {/* Search Bar */} +
+
+ +
+ {isPending ? ( + + ) : ( + + )} +
+ {searchQuery && ( + + )} +
+ {isPending && ( +
+ Searching... +
+ )} +
+ + {/* Results Count */} + {searchQuery && ( +
+ {filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''} found +
+ )} + + {/* List Container */} +
+ {filteredItems.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + <> + {virtualize && filteredItems.length > virtualizeThreshold + ? renderVirtualizedList() + : renderRegularList() + } + + )} +
+ + {/* Selection Summary */} + {enableMultiSelect && selectedIds.size > 0 && ( +
+

+ {selectedIds.size} item{selectedIds.size !== 1 ? 's' : ''} selected +

+
+ )} +
+ ); +} + +/** + * Hook for managing searchable list state + */ +export function useSearchableList( + items: T[], + searchFields: (keyof T)[] = ['title', 'description'] as (keyof T)[] +) { + const [searchQuery, setSearchQuery] = useState(''); + const [isPending, startTransition] = useTransition(); + + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) { + return items; + } + + const query = searchQuery.toLowerCase(); + return items.filter(item => { + return searchFields.some(field => { + const value = item[field]; + if (typeof value === 'string') { + return value.toLowerCase().includes(query); + } + return false; + }); + }); + }, [items, searchQuery, searchFields]); + + const updateSearch = useCallback((query: string) => { + startTransition(() => { + setSearchQuery(query); + }); + }, []); + + const clearSearch = useCallback(() => { + startTransition(() => { + setSearchQuery(''); + }); + }, []); + + return { + searchQuery, + filteredItems, + isPending, + updateSearch, + clearSearch + }; +} \ No newline at end of file diff --git a/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx b/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx index ede71170..f4fc88f8 100644 --- a/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx +++ b/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx @@ -149,7 +149,7 @@ export const KnowledgeItemCard = ({ const [showPageTooltip, setShowPageTooltip] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const [showEditModal, setShowEditModal] = useState(false); - const [loadedCodeExamples, setLoadedCodeExamples] = useState(null); + const [loadedCodeExamples, setLoadedCodeExamples] = useState | null>(null); const [isLoadingCodeExamples, setIsLoadingCodeExamples] = useState(false); const statusColorMap = { diff --git a/archon-ui-main/src/components/project-tasks/DocsTab.tsx b/archon-ui-main/src/components/project-tasks/DocsTab.tsx index 87e6fa5c..075ebb89 100644 --- a/archon-ui-main/src/components/project-tasks/DocsTab.tsx +++ b/archon-ui-main/src/components/project-tasks/DocsTab.tsx @@ -601,21 +601,18 @@ export const DocsTab = ({ try { setIsSaving(true); - // Create a new document with a unique ID - const newDocument: ProjectDoc = { - id: `doc-${Date.now()}`, + // Create document via backend API + const newDocument = await projectService.createDocument(project.id, { title: template.name, content: template.content, - document_type: template.document_type, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }; + document_type: template.document_type + }); // Add to documents list setDocuments(prev => [...prev, newDocument]); setSelectedDocument(newDocument); - console.log('Document created successfully:', newDocument); + console.log('Document created successfully via API:', newDocument); showToast('Document created successfully', 'success'); setShowTemplateModal(false); } catch (error) { @@ -636,18 +633,19 @@ export const DocsTab = ({ try { setIsSaving(true); - // Update the document in local state - const updatedDocument = { - ...selectedDocument, - updated_at: new Date().toISOString() - }; + // Update the document via backend API + const updatedDocument = await projectService.updateDocument(project.id, selectedDocument.id, { + ...selectedDocument, + updated_at: new Date().toISOString() + }); + // Update local state with the response from backend setDocuments(prev => prev.map(doc => doc.id === selectedDocument.id ? updatedDocument : doc )); setSelectedDocument(updatedDocument); - console.log('Document saved successfully:', updatedDocument); + console.log('Document saved successfully via API:', updatedDocument); showToast('Document saved successfully', 'success'); setIsEditing(false); } catch (error) { @@ -938,6 +936,8 @@ export const DocsTab = ({ isActive={selectedDocument?.id === doc.id} onSelect={setSelectedDocument} onDelete={async (docId) => { + if (!project?.id) return; + try { // Call API to delete from database first await projectService.deleteDocument(project.id, docId); @@ -981,22 +981,24 @@ export const DocsTab = ({ document={selectedDocument} isDarkMode={isDarkMode} onSave={async (updatedDocument) => { + if (!project?.id) return; + try { setIsSaving(true); - // Update document with timestamp - const docWithTimestamp = { + // Update document via backend API + const savedDocument = await projectService.updateDocument(project.id, updatedDocument.id, { ...updatedDocument, updated_at: new Date().toISOString() - }; + }); - // Update local state - setSelectedDocument(docWithTimestamp); + // Update local state with the response from backend + setSelectedDocument(savedDocument); setDocuments(prev => prev.map(doc => - doc.id === updatedDocument.id ? docWithTimestamp : doc + doc.id === updatedDocument.id ? savedDocument : doc )); - console.log('Document saved via MilkdownEditor'); + console.log('Document saved via MilkdownEditor API:', savedDocument); showToast('Document saved successfully', 'success'); } catch (error) { console.error('Failed to save document:', error); @@ -1190,7 +1192,7 @@ const TemplateModal: React.FC<{ const KnowledgeSection: React.FC<{ title: string; color: 'blue' | 'purple' | 'pink' | 'orange'; - sources: any[]; + sources: Array<{id: string; title: string; type: string; lastUpdated: string} | undefined>; onAddClick: () => void; }> = ({ title, @@ -1273,7 +1275,7 @@ const KnowledgeSection: React.FC<{ const SourceSelectionModal: React.FC<{ title: string; - sources: any[]; + sources: Array<{id: string; title: string; type: string; lastUpdated: string}>; selectedSources: string[]; onToggleSource: (id: string) => void; onSave: () => void; diff --git a/archon-ui-main/src/components/project-tasks/DocumentCard.tsx b/archon-ui-main/src/components/project-tasks/DocumentCard.tsx index 5ef45a56..26966e9b 100644 --- a/archon-ui-main/src/components/project-tasks/DocumentCard.tsx +++ b/archon-ui-main/src/components/project-tasks/DocumentCard.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Rocket, Code, Briefcase, Users, FileText, X, Plus, Clipboard } from 'lucide-react'; import { useToast } from '../../contexts/ToastContext'; +import { copyToClipboard } from '../../utils/clipboard'; export interface ProjectDoc { id: string; @@ -49,18 +50,22 @@ export const DocumentCard: React.FC = ({ } }; - const handleCopyId = (e: React.MouseEvent) => { + const handleCopyId = async (e: React.MouseEvent) => { e.stopPropagation(); - navigator.clipboard.writeText(document.id); - showToast('Document ID copied to clipboard', 'success'); - - // Visual feedback - const button = e.currentTarget; - const originalHTML = button.innerHTML; - button.innerHTML = '
Copied
'; - setTimeout(() => { - button.innerHTML = originalHTML; - }, 2000); + const success = await copyToClipboard(document.id); + if (success) { + showToast('Document ID copied to clipboard', 'success'); + + // Visual feedback + const button = e.currentTarget; + const originalHTML = button.innerHTML; + button.innerHTML = '
Copied
'; + setTimeout(() => { + button.innerHTML = originalHTML; + }, 2000); + } else { + showToast('Failed to copy Document ID', 'error'); + } }; return ( diff --git a/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx b/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx index b632dea7..166148a4 100644 --- a/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx +++ b/archon-ui-main/src/components/project-tasks/DraggableTaskCard.tsx @@ -3,6 +3,8 @@ import { useDrag, useDrop } from 'react-dnd'; import { Edit, Trash2, RefreshCw, Tag, User, Bot, Clipboard } from 'lucide-react'; import { Task } from './TaskTableView'; import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils'; +import { copyToClipboard } from '../../utils/clipboard'; +import { useToast } from '../../contexts/ToastContext'; export interface DraggableTaskCardProps { task: Task; @@ -27,6 +29,7 @@ export const DraggableTaskCard = ({ hoveredTaskId, onTaskHover, }: DraggableTaskCardProps) => { + const { showToast } = useToast(); const [{ isDragging }, drag] = useDrag({ type: ItemTypes.TASK, @@ -188,16 +191,21 @@ export const DraggableTaskCard = ({ {task.assignee?.name || 'User'} {/* Copy Task ID Button - Matching Board View */} + + + + + + ); +}; + // Mapping functions for status conversion const mapUIStatusToDBStatus = (uiStatus: Task['status']): DatabaseTaskStatus => { switch (uiStatus) { @@ -71,6 +137,8 @@ export const TasksTab = ({ const [isLoadingFeatures, setIsLoadingFeatures] = useState(false); const [isSavingTask, setIsSavingTask] = useState(false); const [isWebSocketConnected, setIsWebSocketConnected] = useState(false); + const [taskToDelete, setTaskToDelete] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // Track recently deleted tasks to prevent race conditions const [recentlyDeletedIds, setRecentlyDeletedIds] = useState>(new Set()); @@ -480,16 +548,27 @@ export const TasksTab = ({ }; const deleteTask = async (task: Task) => { + // Set the task to delete and show confirmation modal + setTaskToDelete(task); + setShowDeleteConfirm(true); + }; + + const confirmDeleteTask = async () => { + if (!taskToDelete) return; + try { - // Delete the task - backend will emit socket event - await projectService.deleteTask(task.id); - console.log(`[TasksTab] Task ${task.id} deletion sent to backend`); + // Delete (actually archives) the task - backend will emit socket event + await projectService.deleteTask(taskToDelete.id); + console.log(`[TasksTab] Task ${taskToDelete.id} archival sent to backend`); // Don't update local state - let socket handle it } catch (error) { - console.error('Failed to delete task:', error); + console.error('Failed to archive task:', error); // Note: The toast notification for deletion is now handled by TaskBoardView and TaskTableView + } finally { + setTaskToDelete(null); + setShowDeleteConfirm(false); } }; @@ -699,6 +778,20 @@ export const TasksTab = ({ onSave={saveTask} getTasksForPrioritySelection={memoizedGetTasksForPrioritySelection} /> + + {/* Delete Confirmation Modal */} + {showDeleteConfirm && taskToDelete && ( + { + setTaskToDelete(null); + setShowDeleteConfirm(false); + }} + title="Archive Task" + message={`Are you sure you want to archive the task "${taskToDelete.title}"? You can restore it from the archived tasks view.`} + confirmText="Archive Task" + /> + )} ); From c22bf07dd341acf6485d320abd855354dfb8e03d Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Wed, 20 Aug 2025 16:19:15 -0400 Subject: [PATCH 03/12] updates to the threading service and crawling from Rasmus PR's --- .../services/crawling/strategies/batch.py | 10 +- .../services/crawling/strategies/recursive.py | 163 ++++++++++-------- .../src/server/services/threading_service.py | 84 ++++++--- 3 files changed, 162 insertions(+), 95 deletions(-) diff --git a/python/src/server/services/crawling/strategies/batch.py b/python/src/server/services/crawling/strategies/batch.py index d97b0bc4..72fc2fae 100644 --- a/python/src/server/services/crawling/strategies/batch.py +++ b/python/src/server/services/crawling/strategies/batch.py @@ -4,7 +4,6 @@ Batch Crawling Strategy Handles batch crawling of multiple URLs in parallel. """ -import asyncio from typing import List, Dict, Any, Optional, Callable from crawl4ai import CrawlerRunConfig, CacheMode, MemoryAdaptiveDispatcher @@ -70,10 +69,12 @@ class BatchCrawlStrategy: except (ValueError, KeyError, TypeError) as e: # Critical configuration errors should fail fast in alpha logger.error(f"Invalid crawl settings format: {e}", exc_info=True) - raise ValueError(f"Failed to load crawler configuration: {e}") + raise ValueError(f"Failed to load crawler configuration: {e}") from e except Exception as e: # For non-critical errors (e.g., network issues), use defaults but log prominently - logger.error(f"Failed to load crawl settings from database: {e}, using defaults", exc_info=True) + logger.error( + f"Failed to load crawl settings from database: {e}, using defaults", exc_info=True + ) batch_size = 50 if max_concurrent is None: max_concurrent = 10 # Safe default to prevent memory issues @@ -91,7 +92,6 @@ class BatchCrawlStrategy: cache_mode=CacheMode.BYPASS, stream=True, # Enable streaming for faster parallel processing markdown_generator=self.markdown_generator, - wait_for="body", # Simple selector for batch wait_until=settings.get("CRAWL_WAIT_STRATEGY", "domcontentloaded"), page_timeout=int(settings.get("CRAWL_PAGE_TIMEOUT", "30000")), delay_before_return_html=float(settings.get("CRAWL_DELAY_BEFORE_HTML", "1.0")), @@ -196,4 +196,4 @@ class BatchCrawlStrategy: end_progress, f"Batch crawling completed: {len(successful_results)}/{total_urls} pages successful", ) - return successful_results + return successful_results \ No newline at end of file diff --git a/python/src/server/services/crawling/strategies/recursive.py b/python/src/server/services/crawling/strategies/recursive.py index d6eb5b38..979c7fac 100644 --- a/python/src/server/services/crawling/strategies/recursive.py +++ b/python/src/server/services/crawling/strategies/recursive.py @@ -3,7 +3,7 @@ Recursive Crawling Strategy Handles recursive crawling of websites by following internal links. """ -import asyncio + from typing import List, Dict, Any, Optional, Callable from urllib.parse import urldefrag @@ -17,11 +17,11 @@ logger = get_logger(__name__) class RecursiveCrawlStrategy: """Strategy for recursive crawling of websites.""" - + def __init__(self, crawler, markdown_generator): """ Initialize recursive crawl strategy. - + Args: crawler (AsyncWebCrawler): The Crawl4AI crawler instance for web crawling operations markdown_generator (DefaultMarkdownGenerator): The markdown generator instance for converting HTML to markdown @@ -29,7 +29,7 @@ class RecursiveCrawlStrategy: self.crawler = crawler self.markdown_generator = markdown_generator self.url_handler = URLHandler() - + async def crawl_recursive_with_progress( self, start_urls: List[str], @@ -39,11 +39,11 @@ class RecursiveCrawlStrategy: max_concurrent: int = None, progress_callback: Optional[Callable] = None, start_progress: int = 10, - end_progress: int = 60 + end_progress: int = 60, ) -> List[Dict[str, Any]]: """ Recursively crawl internal links from start URLs up to a maximum depth with progress reporting. - + Args: start_urls: List of starting URLs transform_url_func: Function to transform URLs (e.g., GitHub URLs) @@ -53,16 +53,16 @@ class RecursiveCrawlStrategy: progress_callback: Optional callback for progress updates start_progress: Starting progress percentage end_progress: Ending progress percentage - + Returns: List of crawl results """ if not self.crawler: logger.error("No crawler instance available for recursive crawling") if progress_callback: - await progress_callback('error', 0, 'Crawler not available') + await progress_callback("error", 0, "Crawler not available") return [] - + # Load settings from database - fail fast on configuration errors try: settings = await credential_service.get_credentials_by_category("rag_strategy") @@ -74,27 +74,30 @@ class RecursiveCrawlStrategy: except (ValueError, KeyError, TypeError) as e: # Critical configuration errors should fail fast in alpha logger.error(f"Invalid crawl settings format: {e}", exc_info=True) - raise ValueError(f"Failed to load crawler configuration: {e}") + raise ValueError(f"Failed to load crawler configuration: {e}") from e except Exception as e: # For non-critical errors (e.g., network issues), use defaults but log prominently - logger.error(f"Failed to load crawl settings from database: {e}, using defaults", exc_info=True) + logger.error( + f"Failed to load crawl settings from database: {e}, using defaults", exc_info=True + ) batch_size = 50 if max_concurrent is None: max_concurrent = 10 # Safe default to prevent memory issues memory_threshold = 80.0 check_interval = 0.5 settings = {} # Empty dict for defaults - + # Check if start URLs include documentation sites has_doc_sites = any(is_documentation_site_func(url) for url in start_urls) - + if has_doc_sites: - logger.info("Detected documentation sites for recursive crawl, using enhanced configuration") + logger.info( + "Detected documentation sites for recursive crawl, using enhanced configuration" + ) run_config = CrawlerRunConfig( cache_mode=CacheMode.BYPASS, stream=True, # Enable streaming for faster parallel processing markdown_generator=self.markdown_generator, - wait_for='body', wait_until=settings.get("CRAWL_WAIT_STRATEGY", "domcontentloaded"), page_timeout=int(settings.get("CRAWL_PAGE_TIMEOUT", "30000")), delay_before_return_html=float(settings.get("CRAWL_DELAY_BEFORE_HTML", "1.0")), @@ -102,7 +105,7 @@ class RecursiveCrawlStrategy: scan_full_page=True, # Trigger lazy loading exclude_all_images=False, remove_overlay_elements=True, - process_iframes=True + process_iframes=True, ) else: # Configuration for regular recursive crawling @@ -113,65 +116,76 @@ class RecursiveCrawlStrategy: wait_until=settings.get("CRAWL_WAIT_STRATEGY", "domcontentloaded"), page_timeout=int(settings.get("CRAWL_PAGE_TIMEOUT", "45000")), delay_before_return_html=float(settings.get("CRAWL_DELAY_BEFORE_HTML", "0.5")), - scan_full_page=True + scan_full_page=True, ) - + dispatcher = MemoryAdaptiveDispatcher( memory_threshold_percent=memory_threshold, check_interval=check_interval, - max_session_permit=max_concurrent + max_session_permit=max_concurrent, ) - + async def report_progress(percentage: int, message: str, **kwargs): """Helper to report progress if callback is available""" if progress_callback: # Add step information for multi-progress tracking - step_info = { - 'currentStep': message, - 'stepMessage': message, - **kwargs - } - await progress_callback('crawling', percentage, message, **step_info) - + step_info = {"currentStep": message, "stepMessage": message, **kwargs} + await progress_callback("crawling", percentage, message, **step_info) + visited = set() - + def normalize_url(url): return urldefrag(url)[0] - + current_urls = set([normalize_url(u) for u in start_urls]) results_all = [] total_processed = 0 - + for depth in range(max_depth): - urls_to_crawl = [normalize_url(url) for url in current_urls if normalize_url(url) not in visited] + urls_to_crawl = [ + normalize_url(url) for url in current_urls if normalize_url(url) not in visited + ] if not urls_to_crawl: break - + # Calculate progress for this depth level - depth_start = start_progress + int((depth / max_depth) * (end_progress - start_progress) * 0.8) - depth_end = start_progress + int(((depth + 1) / max_depth) * (end_progress - start_progress) * 0.8) - - await report_progress(depth_start, f'Crawling depth {depth + 1}/{max_depth}: {len(urls_to_crawl)} URLs to process') - + depth_start = start_progress + int( + (depth / max_depth) * (end_progress - start_progress) * 0.8 + ) + depth_end = start_progress + int( + ((depth + 1) / max_depth) * (end_progress - start_progress) * 0.8 + ) + + await report_progress( + depth_start, + f"Crawling depth {depth + 1}/{max_depth}: {len(urls_to_crawl)} URLs to process", + ) + # Use configured batch size for recursive crawling next_level_urls = set() depth_successful = 0 - + for batch_idx in range(0, len(urls_to_crawl), batch_size): - batch_urls = urls_to_crawl[batch_idx:batch_idx + batch_size] + batch_urls = urls_to_crawl[batch_idx : batch_idx + batch_size] batch_end_idx = min(batch_idx + batch_size, len(urls_to_crawl)) - + # Calculate progress for this batch within the depth - batch_progress = depth_start + int((batch_idx / len(urls_to_crawl)) * (depth_end - depth_start)) - await report_progress(batch_progress, - f'Depth {depth + 1}: crawling URLs {batch_idx + 1}-{batch_end_idx} of {len(urls_to_crawl)}', - totalPages=total_processed + batch_idx, - processedPages=len(results_all)) - + batch_progress = depth_start + int( + (batch_idx / len(urls_to_crawl)) * (depth_end - depth_start) + ) + await report_progress( + batch_progress, + f"Depth {depth + 1}: crawling URLs {batch_idx + 1}-{batch_end_idx} of {len(urls_to_crawl)}", + totalPages=total_processed + batch_idx, + processedPages=len(results_all), + ) + # Use arun_many for native parallel crawling with streaming logger.info(f"Starting parallel crawl of {len(batch_urls)} URLs with arun_many") - batch_results = await self.crawler.arun_many(urls=batch_urls, config=run_config, dispatcher=dispatcher) - + batch_results = await self.crawler.arun_many( + urls=batch_urls, config=run_config, dispatcher=dispatcher + ) + # Handle streaming results from arun_many i = 0 async for result in batch_results: @@ -181,45 +195,58 @@ class RecursiveCrawlStrategy: if transform_url_func(orig_url) == result.url: original_url = orig_url break - + norm_url = normalize_url(original_url) visited.add(norm_url) total_processed += 1 - + if result.success and result.markdown: results_all.append({ - 'url': original_url, - 'markdown': result.markdown, - 'html': result.html # Always use raw HTML for code extraction + "url": original_url, + "markdown": result.markdown, + "html": result.html, # Always use raw HTML for code extraction }) depth_successful += 1 - + # Find internal links for next depth for link in result.links.get("internal", []): next_url = normalize_url(link["href"]) # Skip binary files and already visited URLs - if next_url not in visited and not self.url_handler.is_binary_file(next_url): + if next_url not in visited and not self.url_handler.is_binary_file( + next_url + ): next_level_urls.add(next_url) elif self.url_handler.is_binary_file(next_url): logger.debug(f"Skipping binary file from crawl queue: {next_url}") else: - logger.warning(f"Failed to crawl {original_url}: {getattr(result, 'error_message', 'Unknown error')}") - + logger.warning( + f"Failed to crawl {original_url}: {getattr(result, 'error_message', 'Unknown error')}" + ) + # Report progress every few URLs current_idx = batch_idx + i + 1 if current_idx % 5 == 0 or current_idx == len(urls_to_crawl): - current_progress = depth_start + int((current_idx / len(urls_to_crawl)) * (depth_end - depth_start)) - await report_progress(current_progress, - f'Depth {depth + 1}: processed {current_idx}/{len(urls_to_crawl)} URLs ({depth_successful} successful)', - totalPages=total_processed, - processedPages=len(results_all)) + current_progress = depth_start + int( + (current_idx / len(urls_to_crawl)) * (depth_end - depth_start) + ) + await report_progress( + current_progress, + f"Depth {depth + 1}: processed {current_idx}/{len(urls_to_crawl)} URLs ({depth_successful} successful)", + totalPages=total_processed, + processedPages=len(results_all), + ) i += 1 - + current_urls = next_level_urls - + # Report completion of this depth - await report_progress(depth_end, - f'Depth {depth + 1} completed: {depth_successful} pages crawled, {len(next_level_urls)} URLs found for next depth') - - await report_progress(end_progress, f'Recursive crawling completed: {len(results_all)} total pages crawled across {max_depth} depth levels') + await report_progress( + depth_end, + f"Depth {depth + 1} completed: {depth_successful} pages crawled, {len(next_level_urls)} URLs found for next depth", + ) + + await report_progress( + end_progress, + f"Recursive crawling completed: {len(results_all)} total pages crawled across {max_depth} depth levels", + ) return results_all \ No newline at end of file diff --git a/python/src/server/services/threading_service.py b/python/src/server/services/threading_service.py index a5ad6cbd..12d69b63 100644 --- a/python/src/server/services/threading_service.py +++ b/python/src/server/services/threading_service.py @@ -93,18 +93,19 @@ class RateLimiter: self._clean_old_entries(now) # Check if we can make the request - while not self._can_make_request(estimated_tokens): + if not self._can_make_request(estimated_tokens): wait_time = self._calculate_wait_time(estimated_tokens) if wait_time > 0: logfire_logger.info( - f"Rate limiting: waiting {wait_time:.1f}s (tokens={estimated_tokens}, current_usage={self._get_current_usage()})" + f"Rate limiting: waiting {wait_time:.1f}s", + extra={ + "tokens": estimated_tokens, + "current_usage": self._get_current_usage(), + } ) await asyncio.sleep(wait_time) - # Clean old entries after waiting - now = time.time() - self._clean_old_entries(now) - else: - return False + return await self.acquire(estimated_tokens) + return False # Record the request self.request_times.append(now) @@ -199,13 +200,21 @@ class MemoryAdaptiveDispatcher: # Reduce workers when memory is high workers = max(1, base // 2) logfire_logger.warning( - f"High memory usage detected, reducing workers (memory_percent={metrics.memory_percent}, workers={workers})" + "High memory usage detected, reducing workers", + extra={ + "memory_percent": metrics.memory_percent, + "workers": workers, + } ) elif metrics.cpu_percent > self.config.cpu_threshold * 100: # Reduce workers when CPU is high workers = max(1, base // 2) logfire_logger.warning( - f"High CPU usage detected, reducing workers (cpu_percent={metrics.cpu_percent}, workers={workers})" + "High CPU usage detected, reducing workers", + extra={ + "cpu_percent": metrics.cpu_percent, + "workers": workers, + } ) elif metrics.memory_percent < 50 and metrics.cpu_percent < 50: # Increase workers when resources are available @@ -235,7 +244,14 @@ class MemoryAdaptiveDispatcher: semaphore = asyncio.Semaphore(optimal_workers) logfire_logger.info( - f"Starting adaptive processing (items_count={len(items)}, workers={optimal_workers}, mode={mode}, memory_percent={self.last_metrics.memory_percent}, cpu_percent={self.last_metrics.cpu_percent})" + "Starting adaptive processing", + extra={ + "items_count": len(items), + "workers": optimal_workers, + "mode": mode, + "memory_percent": self.last_metrics.memory_percent, + "cpu_percent": self.last_metrics.cpu_percent, + } ) # Track active workers @@ -310,7 +326,8 @@ class MemoryAdaptiveDispatcher: del active_workers[worker_id] logfire_logger.error( - f"Processing failed for item {index} (error={str(e)}, item_index={index})" + f"Processing failed for item {index}", + extra={"error": str(e), "item_index": index} ) return None @@ -325,7 +342,13 @@ class MemoryAdaptiveDispatcher: success_rate = len(successful_results) / len(items) * 100 logfire_logger.info( - f"Adaptive processing completed (total_items={len(items)}, successful={len(successful_results)}, success_rate={success_rate:.1f}%, workers_used={optimal_workers})" + "Adaptive processing completed", + extra={ + "total_items": len(items), + "successful": len(successful_results), + "success_rate": f"{success_rate:.1f}%", + "workers_used": optimal_workers, + } ) return successful_results @@ -343,7 +366,8 @@ class WebSocketSafeProcessor: await websocket.accept() self.active_connections.append(websocket) logfire_logger.info( - f"WebSocket client connected (total_connections={len(self.active_connections)})" + "WebSocket client connected", + extra={"total_connections": len(self.active_connections)} ) def disconnect(self, websocket: WebSocket): @@ -351,7 +375,8 @@ class WebSocketSafeProcessor: if websocket in self.active_connections: self.active_connections.remove(websocket) logfire_logger.info( - f"WebSocket client disconnected (remaining_connections={len(self.active_connections)})" + "WebSocket client disconnected", + extra={"remaining_connections": len(self.active_connections)} ) async def broadcast_progress(self, message: dict[str, Any]): @@ -462,7 +487,7 @@ class ThreadingService: self._running = True self._health_check_task = asyncio.create_task(self._health_check_loop()) - logfire_logger.info(f"Threading service started (config={self.config.__dict__})") + logfire_logger.info("Threading service started", extra={"config": self.config.__dict__}) async def stop(self): """Stop the threading service""" @@ -498,7 +523,8 @@ class ThreadingService: finally: duration = time.time() - start_time logfire_logger.debug( - f"Rate limited operation completed (duration={duration}, tokens={estimated_tokens})" + "Rate limited operation completed", + extra={"duration": duration, "tokens": estimated_tokens}, ) async def run_cpu_intensive(self, func: Callable, *args, **kwargs) -> Any: @@ -550,30 +576,44 @@ class ThreadingService: # Log system metrics logfire_logger.info( - f"System health check (memory_percent={metrics.memory_percent}, cpu_percent={metrics.cpu_percent}, available_memory_gb={metrics.available_memory_gb}, active_threads={metrics.active_threads}, active_websockets={len(self.websocket_processor.active_connections)})" + "System health check", + extra={ + "memory_percent": metrics.memory_percent, + "cpu_percent": metrics.cpu_percent, + "available_memory_gb": metrics.available_memory_gb, + "active_threads": metrics.active_threads, + "active_websockets": len(self.websocket_processor.active_connections), + } ) # Alert on critical thresholds if metrics.memory_percent > 90: logfire_logger.warning( - f"Critical memory usage (memory_percent={metrics.memory_percent})" + "Critical memory usage", + extra={"memory_percent": metrics.memory_percent} ) # Force garbage collection gc.collect() if metrics.cpu_percent > 95: - logfire_logger.warning(f"Critical CPU usage (cpu_percent={metrics.cpu_percent})") + logfire_logger.warning( + "Critical CPU usage", extra={"cpu_percent": metrics.cpu_percent} + ) # Check for memory leaks (too many threads) if metrics.active_threads > self.config.max_workers * 3: logfire_logger.warning( - f"High thread count detected (active_threads={metrics.active_threads}, max_expected={self.config.max_workers * 3})" + "High thread count detected", + extra={ + "active_threads": metrics.active_threads, + "max_expected": self.config.max_workers * 3, + } ) await asyncio.sleep(self.config.health_check_interval) except Exception as e: - logfire_logger.error(f"Health check failed (error={str(e)})") + logfire_logger.error("Health check failed", extra={"error": str(e)}) await asyncio.sleep(self.config.health_check_interval) @@ -601,4 +641,4 @@ async def stop_threading_service(): global _threading_service if _threading_service: await _threading_service.stop() - _threading_service = None + _threading_service = None \ No newline at end of file From c16498ceabd1416c07de569fd4c9868fb8f01123 Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Wed, 20 Aug 2025 21:16:12 -0400 Subject: [PATCH 04/12] - Fixing the crawl errors for large crawled files like the NUXT docs. - Removing the "Completed" steps in reporting. - Cleanup Sockets from PR 250 and 395 with Code Rabbit cleanup suggestions. --- archon-ui-main/test/config/api.test.ts | 79 +-------- .../crawling/code_extraction_service.py | 133 +++++++++++++-- .../services/crawling/crawling_service.py | 3 + .../services/crawling/strategies/batch.py | 160 +++++++++--------- .../services/crawling/strategies/recursive.py | 31 +++- .../src/server/services/threading_service.py | 52 +++--- python/src/server/socketio_app.py | 31 ++-- 7 files changed, 279 insertions(+), 210 deletions(-) diff --git a/archon-ui-main/test/config/api.test.ts b/archon-ui-main/test/config/api.test.ts index ac06c78e..95e2e992 100644 --- a/archon-ui-main/test/config/api.test.ts +++ b/archon-ui-main/test/config/api.test.ts @@ -47,10 +47,10 @@ describe('API Configuration', () => { delete (import.meta.env as any).VITE_API_URL; delete (import.meta.env as any).ARCHON_SERVER_PORT; - const { getApiUrl } = await import('../../src/config/api'); - - expect(() => getApiUrl()).toThrow('ARCHON_SERVER_PORT environment variable is required'); - expect(() => getApiUrl()).toThrow('Default value: 8181'); + // The error will be thrown during module import because API_FULL_URL calls getApiUrl() + await expect(async () => { + await import('../../src/config/api'); + }).rejects.toThrow('ARCHON_SERVER_PORT environment variable is required'); }); it('should use ARCHON_SERVER_PORT when set in development', async () => { @@ -156,73 +156,4 @@ describe('API Configuration', () => { }); }); -describe('MCP Client Service Configuration', () => { - let originalEnv: any; - - beforeEach(() => { - originalEnv = { ...import.meta.env }; - vi.resetModules(); - }); - - afterEach(() => { - Object.keys(import.meta.env).forEach(key => { - delete (import.meta.env as any)[key]; - }); - Object.assign(import.meta.env, originalEnv); - }); - - it('should throw error when ARCHON_MCP_PORT is not set', async () => { - delete (import.meta.env as any).ARCHON_MCP_PORT; - - const { MCPClientService } = await import('../../src/services/mcpClientService'); - const service = new MCPClientService(); - - await expect(service.createArchonClient()).rejects.toThrow('ARCHON_MCP_PORT environment variable is required'); - await expect(service.createArchonClient()).rejects.toThrow('Default value: 8051'); - }); - - it('should use ARCHON_MCP_PORT when set', async () => { - (import.meta.env as any).ARCHON_MCP_PORT = '9051'; - (import.meta.env as any).ARCHON_SERVER_PORT = '8181'; - - // Mock window.location - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: 'localhost' - }, - writable: true - }); - - // Mock the API call - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'test-id', - name: 'Archon', - transport_type: 'http', - connection_status: 'connected' - }) - }); - - const { MCPClientService } = await import('../../src/services/mcpClientService'); - const service = new MCPClientService(); - - try { - await service.createArchonClient(); - - // Verify the fetch was called with the correct URL - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/mcp/clients'), - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('9051') - }) - ); - } catch (error) { - // If it fails due to actual API call, that's okay for this test - // We're mainly testing that it constructs the URL correctly - expect(error).toBeDefined(); - } - }); -}); \ No newline at end of file +// MCP Client Service Configuration tests removed - service not currently in use \ No newline at end of file diff --git a/python/src/server/services/crawling/code_extraction_service.py b/python/src/server/services/crawling/code_extraction_service.py index 71e12ebe..d0649baf 100644 --- a/python/src/server/services/crawling/code_extraction_service.py +++ b/python/src/server/services/crawling/code_extraction_service.py @@ -211,14 +211,21 @@ class CodeExtractionService: Returns: List of code blocks with metadata """ + import asyncio + import time + # Progress will be reported during the loop below all_code_blocks = [] total_docs = len(crawl_results) completed_docs = 0 + + # PERFORMANCE: Track extraction time per document + MAX_EXTRACTION_TIME_PER_DOC = 5.0 # 5 seconds max per document for doc in crawl_results: try: + doc_start_time = time.time() source_url = doc["url"] html_content = doc.get("html", "") md = doc.get("markdown", "") @@ -228,9 +235,7 @@ class CodeExtractionService: f"Document content check | url={source_url} | has_html={bool(html_content)} | has_markdown={bool(md)} | html_len={len(html_content) if html_content else 0} | md_len={len(md) if md else 0}" ) - # Get dynamic minimum length based on document context - # Extract some context from the document for analysis - doc_context = md[:1000] if md else html_content[:1000] if html_content else "" + # Dynamic minimum length is handled inside the extraction methods # Check markdown first to see if it has code blocks if md: @@ -281,15 +286,32 @@ class CodeExtractionService: # If not a text file or no code blocks found, try HTML extraction first if len(code_blocks) == 0 and html_content and not is_text_file: - safe_logfire_info( - f"Trying HTML extraction first | url={source_url} | html_length={len(html_content)}" - ) - html_code_blocks = await self._extract_html_code_blocks(html_content) - if html_code_blocks: - code_blocks = html_code_blocks + # PERFORMANCE: Check if we've already spent too much time on this document + elapsed_time = time.time() - doc_start_time + if elapsed_time > MAX_EXTRACTION_TIME_PER_DOC: safe_logfire_info( - f"Found {len(code_blocks)} code blocks from HTML | url={source_url}" + f"⏱️ Skipping HTML extraction for {source_url} - already spent {elapsed_time:.1f}s" ) + else: + safe_logfire_info( + f"Trying HTML extraction first | url={source_url} | html_length={len(html_content)}" + ) + # Create a timeout for HTML extraction + remaining_time = MAX_EXTRACTION_TIME_PER_DOC - elapsed_time + try: + html_code_blocks = await asyncio.wait_for( + self._extract_html_code_blocks(html_content, source_url), + timeout=remaining_time + ) + if html_code_blocks: + code_blocks = html_code_blocks + safe_logfire_info( + f"Found {len(code_blocks)} code blocks from HTML | url={source_url}" + ) + except asyncio.TimeoutError: + safe_logfire_info( + f"⏱️ HTML extraction timed out after {remaining_time:.1f}s for {source_url}" + ) # If still no code blocks, try markdown extraction as fallback if len(code_blocks) == 0 and md and "```" in md: @@ -319,6 +341,14 @@ class CodeExtractionService: # Update progress only after completing document extraction completed_docs += 1 + extraction_time = time.time() - doc_start_time + if extraction_time > 2.0: # Log slow extractions + safe_logfire_info( + f"⏱️ Document extraction took {extraction_time:.1f}s | url={source_url} | " + f"html_size={len(html_content) if html_content else 0} | " + f"blocks_found={len([b for b in all_code_blocks if b['source_url'] == source_url])}" + ) + if progress_callback and total_docs > 0: # Calculate progress within the specified range raw_progress = completed_docs / total_docs @@ -340,13 +370,14 @@ class CodeExtractionService: return all_code_blocks - async def _extract_html_code_blocks(self, content: str) -> list[dict[str, Any]]: + async def _extract_html_code_blocks(self, content: str, source_url: str = "") -> list[dict[str, Any]]: """ Extract code blocks from HTML patterns in content. This is a fallback when markdown conversion didn't preserve code blocks. Args: content: The content to search for HTML code patterns + source_url: The URL of the document being processed min_length: Minimum length for code blocks Returns: @@ -356,6 +387,20 @@ class CodeExtractionService: # Add detailed logging safe_logfire_info(f"Processing HTML of length {len(content)} for code extraction") + + # PERFORMANCE OPTIMIZATION: Skip extremely large HTML files or chunk them + MAX_HTML_SIZE = 1_000_000 # 1MB limit for single-pass processing (increased from 500KB) + if len(content) > MAX_HTML_SIZE: + safe_logfire_info( + f"⚠️ HTML content is very large ({len(content)} bytes). " + f"Limiting to first {MAX_HTML_SIZE} bytes to prevent timeout." + ) + # For very large files, focus on the first portion where code examples are likely to be + content = content[:MAX_HTML_SIZE] + # Try to find a good cutoff point (end of a tag) + last_tag_end = content.rfind('>') + if last_tag_end > MAX_HTML_SIZE - 1000: + content = content[:last_tag_end + 1] # Check if we have actual content if len(content) < 1000: @@ -507,9 +552,71 @@ class CodeExtractionService: ), ] - for pattern_tuple in patterns: + # PERFORMANCE: Early exit checks to avoid unnecessary regex processing + # Check more content (20KB instead of 5KB) and add URL-based exceptions + check_size = min(20000, len(content)) # Check first 20KB or entire content if smaller + has_code_indicators = any(indicator in content[:check_size] for indicator in + [' bool: """Acquire permission to make API call with token awareness""" async with self._lock: - now = time.time() + while True: # Use a loop instead of recursion + now = time.time() - # Clean old entries - self._clean_old_entries(now) + # Clean old entries + self._clean_old_entries(now) - # Check if we can make the request - if not self._can_make_request(estimated_tokens): + # Check if we can make the request + if self._can_make_request(estimated_tokens): + # Record the request + self.request_times.append(now) + self.token_usage.append((now, estimated_tokens)) + return True + + # Calculate wait time wait_time = self._calculate_wait_time(estimated_tokens) - if wait_time > 0: - logfire_logger.info( - f"Rate limiting: waiting {wait_time:.1f}s", - extra={ - "tokens": estimated_tokens, - "current_usage": self._get_current_usage(), - } - ) - await asyncio.sleep(wait_time) - return await self.acquire(estimated_tokens) - return False + if wait_time <= 0: + return False - # Record the request - self.request_times.append(now) - self.token_usage.append((now, estimated_tokens)) - return True + logfire_logger.info( + f"Rate limiting: waiting {wait_time:.1f}s", + extra={ + "tokens": estimated_tokens, + "current_usage": self._get_current_usage(), + } + ) + + # Release the lock while sleeping to allow other operations + self._lock.release() + try: + await asyncio.sleep(wait_time) + logfire_logger.info(f"Rate limiting: resuming after {wait_time:.1f}s wait") + finally: + # Re-acquire the lock before continuing + await self._lock.acquire() + + # Loop will continue and re-check conditions def _can_make_request(self, estimated_tokens: int) -> bool: """Check if request can be made within limits""" diff --git a/python/src/server/socketio_app.py b/python/src/server/socketio_app.py index 0028d66b..9231751d 100644 --- a/python/src/server/socketio_app.py +++ b/python/src/server/socketio_app.py @@ -26,16 +26,6 @@ sio = socketio.AsyncServer( ping_interval=60, # 1 minute - check connection every minute ) -# Global Socket.IO instance for use across modules -_socketio_instance: socketio.AsyncServer | None = None - -def get_socketio_instance() -> socketio.AsyncServer: - """Get the global Socket.IO server instance.""" - global _socketio_instance - if _socketio_instance is None: - _socketio_instance = sio - return _socketio_instance - def create_socketio_app(app: FastAPI) -> socketio.ASGIApp: """ @@ -62,3 +52,24 @@ def create_socketio_app(app: FastAPI) -> socketio.ASGIApp: sio.app = app return socket_app + +# Default Socket.IO event handlers +@sio.event +async def connect(sid, environ): + """Handle new client connections.""" + logger.info(f"Client connected: {sid}") + safe_logfire_info(f"Client connected: {sid}") + + +@sio.event +async def disconnect(sid): + """Handle client disconnections.""" + logger.info(f"Client disconnected: {sid}") + safe_logfire_info(f"Client disconnected: {sid}") + + +@sio.event +async def message(sid, data): + """Handle incoming messages.""" + logger.info(f"Received message from {sid}: {data}") + await sio.emit("response", {"data": "Message received!"}, to=sid) \ No newline at end of file From 703f2bca7c26f9f464264d07d899f747de56918d Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Wed, 20 Aug 2025 23:31:47 -0400 Subject: [PATCH 05/12] Fixed the socket optimistic updates. And the MCP for update task. --- .../src/components/project-tasks/TasksTab.tsx | 96 ++++++++++++++----- archon-ui-main/src/hooks/useTaskSocket.ts | 65 ++++++++++++- .../src/services/socketIOService.ts | 33 ++----- .../src/services/taskSocketService.ts | 21 ++-- .../mcp_server/features/tasks/task_tools.py | 63 ++++++++---- 5 files changed, 200 insertions(+), 78 deletions(-) diff --git a/archon-ui-main/src/components/project-tasks/TasksTab.tsx b/archon-ui-main/src/components/project-tasks/TasksTab.tsx index 60f862ad..288ff265 100644 --- a/archon-ui-main/src/components/project-tasks/TasksTab.tsx +++ b/archon-ui-main/src/components/project-tasks/TasksTab.tsx @@ -511,33 +511,50 @@ export const TasksTab = ({ debouncedPersistBatchReorder(tasksToUpdate); }, [tasks, updateTasks, debouncedPersistBatchReorder]); - // Task move function (for board view) + // Task move function (for board view) with optimistic UI update const moveTask = async (taskId: string, newStatus: Task['status']) => { console.log(`[TasksTab] Attempting to move task ${taskId} to new status: ${newStatus}`); + + const movingTask = tasks.find(task => task.id === taskId); + if (!movingTask) { + console.warn(`[TasksTab] Task ${taskId} not found for move operation.`); + return; + } + + const oldStatus = movingTask.status; + const newOrder = getNextOrderForStatus(newStatus); + const updatedTask = { ...movingTask, status: newStatus, task_order: newOrder }; + + console.log(`[TasksTab] Moving task ${movingTask.title} from ${oldStatus} to ${newStatus} with order ${newOrder}`); + + // OPTIMISTIC UPDATE: Update UI immediately + setTasks(prev => { + const updated = prev.map(task => task.id === taskId ? updatedTask : task); + setTimeout(() => onTasksChange(updated), 0); + return updated; + }); + console.log(`[TasksTab] Optimistically updated UI for task ${taskId}`); + try { - const movingTask = tasks.find(task => task.id === taskId); - if (!movingTask) { - console.warn(`[TasksTab] Task ${taskId} not found for move operation.`); - return; - } - - const oldStatus = movingTask.status; - const newOrder = getNextOrderForStatus(newStatus); - - console.log(`[TasksTab] Moving task ${movingTask.title} from ${oldStatus} to ${newStatus} with order ${newOrder}`); - - // Update the task with new status and order + // Then update the backend await projectService.updateTask(taskId, { status: mapUIStatusToDBStatus(newStatus), task_order: newOrder }); console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`); - // Don't update local state immediately - let socket handle it - console.log(`[TasksTab] Waiting for socket update for task ${taskId}.`); + // Socket will confirm the update, but UI is already updated } catch (error) { - console.error(`[TasksTab] Failed to move task ${taskId}:`, error); + console.error(`[TasksTab] Failed to move task ${taskId}, rolling back:`, error); + + // ROLLBACK on error - restore original task + setTasks(prev => { + const updated = prev.map(task => task.id === taskId ? movingTask : task); + setTimeout(() => onTasksChange(updated), 0); + return updated; + }); + alert(`Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; @@ -557,15 +574,50 @@ export const TasksTab = ({ if (!taskToDelete) return; try { - // Delete (actually archives) the task - backend will emit socket event - await projectService.deleteTask(taskToDelete.id); - console.log(`[TasksTab] Task ${taskToDelete.id} archival sent to backend`); + // Add to recently deleted cache to prevent race conditions + setRecentlyDeletedIds(prev => new Set(prev).add(taskToDelete.id)); - // Don't update local state - let socket handle it + // OPTIMISTIC UPDATE: Remove task from UI immediately + setTasks(prev => { + const updated = prev.filter(t => t.id !== taskToDelete.id); + setTimeout(() => onTasksChange(updated), 0); + return updated; + }); + console.log(`[TasksTab] Optimistically removed task ${taskToDelete.id} from UI`); + + // Then delete from backend + await projectService.deleteTask(taskToDelete.id); + console.log(`[TasksTab] Task ${taskToDelete.id} deletion confirmed by backend`); + + // Clear from recently deleted cache after a delay (to catch any lingering socket events) + setTimeout(() => { + setRecentlyDeletedIds(prev => { + const newSet = new Set(prev); + newSet.delete(taskToDelete.id); + return newSet; + }); + }, 3000); // 3 second window to ignore stale socket events } catch (error) { - console.error('Failed to archive task:', error); - // Note: The toast notification for deletion is now handled by TaskBoardView and TaskTableView + console.error('Failed to delete task:', error); + + // Remove from recently deleted cache on error + setRecentlyDeletedIds(prev => { + const newSet = new Set(prev); + newSet.delete(taskToDelete.id); + return newSet; + }); + + // ROLLBACK on error - restore the task + setTasks(prev => { + const updated = [...prev, taskToDelete].sort((a, b) => a.task_order - b.task_order); + setTimeout(() => onTasksChange(updated), 0); + return updated; + }); + console.log(`[TasksTab] Rolled back task deletion for ${taskToDelete.id}`); + + // Re-throw to let the calling component handle the error display + throw error; } finally { setTaskToDelete(null); setShowDeleteConfirm(false); diff --git a/archon-ui-main/src/hooks/useTaskSocket.ts b/archon-ui-main/src/hooks/useTaskSocket.ts index 05b3aecc..376bb501 100644 --- a/archon-ui-main/src/hooks/useTaskSocket.ts +++ b/archon-ui-main/src/hooks/useTaskSocket.ts @@ -6,7 +6,7 @@ * approach that avoids conflicts and connection issues. */ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { taskSocketService, TaskSocketEvents } from '../services/taskSocketService'; import { WebSocketState } from '../services/socketIOService'; @@ -36,6 +36,10 @@ export function useTaskSocket(options: UseTaskSocketOptions) { const componentIdRef = useRef(`task-socket-${Math.random().toString(36).substring(7)}`); const currentProjectIdRef = useRef(null); const isInitializedRef = useRef(false); + + // Add reactive state for connection status + const [isConnected, setIsConnected] = useState(false); + const [connectionState, setConnectionState] = useState(WebSocketState.DISCONNECTED); // Memoized handlers to prevent unnecessary re-registrations const memoizedHandlers = useCallback((): Partial => { @@ -58,6 +62,44 @@ export function useTaskSocket(options: UseTaskSocketOptions) { onConnectionStateChange ]); + // Subscribe to connection state changes + useEffect(() => { + const checkConnection = () => { + const connected = taskSocketService.isConnected(); + const state = taskSocketService.getConnectionState(); + setIsConnected(connected); + setConnectionState(state); + }; + + // Check initial state + checkConnection(); + + // Poll for connection state changes (since the service doesn't expose event emitters) + const interval = setInterval(checkConnection, 500); + + // Also trigger when connection state handler is called + const wrappedOnConnectionStateChange = onConnectionStateChange ? (state: WebSocketState) => { + setConnectionState(state); + setIsConnected(state === WebSocketState.CONNECTED); + onConnectionStateChange(state); + } : (state: WebSocketState) => { + setConnectionState(state); + setIsConnected(state === WebSocketState.CONNECTED); + }; + + // Update the handler + if (componentIdRef.current && taskSocketService) { + taskSocketService.registerHandlers(componentIdRef.current, { + ...memoizedHandlers(), + onConnectionStateChange: wrappedOnConnectionStateChange + }); + } + + return () => { + clearInterval(interval); + }; + }, [onConnectionStateChange, memoizedHandlers]); + // Initialize connection once and register handlers useEffect(() => { if (!projectId || isInitializedRef.current) return; @@ -65,6 +107,7 @@ export function useTaskSocket(options: UseTaskSocketOptions) { const initializeConnection = async () => { try { console.log(`[USE_TASK_SOCKET] Initializing connection for project: ${projectId}`); + setConnectionState(WebSocketState.CONNECTING); // Register handlers first taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers()); @@ -76,8 +119,14 @@ export function useTaskSocket(options: UseTaskSocketOptions) { isInitializedRef.current = true; console.log(`[USE_TASK_SOCKET] Successfully initialized for project: ${projectId}`); + // Update connection state after successful connection + setIsConnected(taskSocketService.isConnected()); + setConnectionState(taskSocketService.getConnectionState()); + } catch (error) { console.error(`[USE_TASK_SOCKET] Failed to initialize for project ${projectId}:`, error); + setConnectionState(WebSocketState.DISCONNECTED); + setIsConnected(false); } }; @@ -103,6 +152,8 @@ export function useTaskSocket(options: UseTaskSocketOptions) { const switchProject = async () => { try { + setConnectionState(WebSocketState.CONNECTING); + // Update handlers for new project taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers()); @@ -112,8 +163,14 @@ export function useTaskSocket(options: UseTaskSocketOptions) { currentProjectIdRef.current = projectId; console.log(`[USE_TASK_SOCKET] Successfully switched to project: ${projectId}`); + // Update connection state + setIsConnected(taskSocketService.isConnected()); + setConnectionState(taskSocketService.getConnectionState()); + } catch (error) { console.error(`[USE_TASK_SOCKET] Failed to switch to project ${projectId}:`, error); + setConnectionState(WebSocketState.DISCONNECTED); + setIsConnected(false); } }; @@ -132,10 +189,10 @@ export function useTaskSocket(options: UseTaskSocketOptions) { }; }, []); - // Return utility functions + // Return reactive state and utility functions return { - isConnected: taskSocketService.isConnected(), - connectionState: taskSocketService.getConnectionState(), + isConnected, // Now reactive! + connectionState, // Now reactive! reconnect: taskSocketService.reconnect.bind(taskSocketService), getCurrentProjectId: taskSocketService.getCurrentProjectId.bind(taskSocketService) }; diff --git a/archon-ui-main/src/services/socketIOService.ts b/archon-ui-main/src/services/socketIOService.ts index 35f2cccc..47db04e6 100644 --- a/archon-ui-main/src/services/socketIOService.ts +++ b/archon-ui-main/src/services/socketIOService.ts @@ -678,28 +678,15 @@ export function createWebSocketService(config?: WebSocketConfig): WebSocketServi return new WebSocketService(config); } -// Create SEPARATE WebSocket instances for different features -// This prevents a failure in one feature (like a long crawl) from breaking the entire site -export const knowledgeSocketIO = new WebSocketService({ - maxReconnectAttempts: 10, // More attempts for crawls that can take a long time - reconnectInterval: 2000, - heartbeatInterval: 30000, - enableAutoReconnect: true -}); +// Create a SINGLE shared WebSocket instance to prevent multiple connections +// This fixes the socket disconnection issue when switching tabs +const sharedSocketInstance = new WebSocketService(); -export const taskUpdateSocketIO = new WebSocketService({ - maxReconnectAttempts: 5, - reconnectInterval: 1000, - heartbeatInterval: 30000, - enableAutoReconnect: true -}); +// Export the SAME instance with different names for backward compatibility +// This ensures only ONE Socket.IO connection is created and shared across all features +export const knowledgeSocketIO = sharedSocketInstance; +export const taskUpdateSocketIO = sharedSocketInstance; +export const projectListSocketIO = sharedSocketInstance; -export const projectListSocketIO = new WebSocketService({ - maxReconnectAttempts: 5, - reconnectInterval: 1000, - heartbeatInterval: 30000, - enableAutoReconnect: true -}); - -// Export knowledgeSocketIO as default for backward compatibility -export default knowledgeSocketIO; \ No newline at end of file +// Export as default for new code +export default sharedSocketInstance; \ No newline at end of file diff --git a/archon-ui-main/src/services/taskSocketService.ts b/archon-ui-main/src/services/taskSocketService.ts index 51c0a9df..caca6586 100644 --- a/archon-ui-main/src/services/taskSocketService.ts +++ b/archon-ui-main/src/services/taskSocketService.ts @@ -13,7 +13,8 @@ * - Proper session identification */ -import { WebSocketService, WebSocketState } from './socketIOService'; +import { WebSocketState } from './socketIOService'; +import sharedSocketInstance from './socketIOService'; export interface Task { id: string; @@ -38,7 +39,7 @@ export interface TaskSocketEvents { class TaskSocketService { private static instance: TaskSocketService | null = null; - private socketService: WebSocketService; + private socketService: typeof sharedSocketInstance; private currentProjectId: string | null = null; private eventHandlers: Map = new Map(); private connectionPromise: Promise | null = null; @@ -47,13 +48,11 @@ class TaskSocketService { private connectionCooldown = 1000; // 1 second cooldown between connection attempts private constructor() { - this.socketService = new WebSocketService({ - maxReconnectAttempts: 5, - reconnectInterval: 1000, - heartbeatInterval: 30000, - enableAutoReconnect: true, - enableHeartbeat: true - }); + // Use the shared socket instance instead of creating a new one + this.socketService = sharedSocketInstance; + + // Enable operation tracking for echo suppression + this.socketService.enableOperationTracking(); // Set up global event handlers this.setupGlobalHandlers(); @@ -191,7 +190,7 @@ class TaskSocketService { const joinSuccess = this.socketService.send({ type: 'join_project', project_id: projectId - }); + }, true); // Enable operation tracking if (!joinSuccess) { throw new Error('Failed to send join_project message'); @@ -214,7 +213,7 @@ class TaskSocketService { this.socketService.send({ type: 'leave_project', project_id: this.currentProjectId - }); + }, true); // Enable operation tracking this.currentProjectId = null; } diff --git a/python/src/mcp_server/features/tasks/task_tools.py b/python/src/mcp_server/features/tasks/task_tools.py index 024f44ed..bc1d9ed3 100644 --- a/python/src/mcp_server/features/tasks/task_tools.py +++ b/python/src/mcp_server/features/tasks/task_tools.py @@ -7,7 +7,7 @@ Mirrors the functionality of the original manage_task tool but with individual t import json import logging -from typing import Any, Dict, List, Optional, TypedDict +from typing import Any, Dict, List, Optional from urllib.parse import urljoin import httpx @@ -20,19 +20,6 @@ from src.server.config.service_discovery import get_api_url logger = logging.getLogger(__name__) -class TaskUpdateFields(TypedDict, total=False): - """Valid fields that can be updated on a task.""" - - title: str - description: str - status: str # "todo" | "doing" | "review" | "done" - assignee: str # "User" | "Archon" | "AI IDE Agent" | "prp-executor" | "prp-validator" - task_order: int # 0-100, higher = more priority - feature: Optional[str] - sources: Optional[List[Dict[str, str]]] - code_examples: Optional[List[Dict[str, str]]] - - def register_task_tools(mcp: FastMCP): """Register individual task management tools with the MCP server.""" @@ -315,26 +302,66 @@ def register_task_tools(mcp: FastMCP): async def update_task( ctx: Context, task_id: str, - update_fields: TaskUpdateFields, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[str] = None, + assignee: Optional[str] = None, + task_order: Optional[int] = None, + feature: Optional[str] = None, + sources: Optional[List[Dict[str, str]]] = None, + code_examples: Optional[List[Dict[str, str]]] = None, ) -> str: """ Update a task's properties. Args: task_id: UUID of the task to update - update_fields: Dict of fields to update (e.g., {"status": "doing", "assignee": "AI IDE Agent"}) + title: New task title (optional) + description: New task description (optional) + status: New status - "todo" | "doing" | "review" | "done" (optional) + assignee: New assignee (optional) + task_order: New priority order (optional) + feature: New feature label (optional) + sources: New source references (optional) + code_examples: New code examples (optional) Returns: JSON with updated task details Examples: - update_task(task_id="uuid", update_fields={"status": "doing"}) - update_task(task_id="uuid", update_fields={"title": "New Title", "description": "Updated description"}) + update_task(task_id="uuid", status="doing") + update_task(task_id="uuid", title="New Title", description="Updated description") """ try: api_url = get_api_url() timeout = get_default_timeout() + # Build update_fields dict from provided parameters + update_fields = {} + if title is not None: + update_fields["title"] = title + if description is not None: + update_fields["description"] = description + if status is not None: + update_fields["status"] = status + if assignee is not None: + update_fields["assignee"] = assignee + if task_order is not None: + update_fields["task_order"] = task_order + if feature is not None: + update_fields["feature"] = feature + if sources is not None: + update_fields["sources"] = sources + if code_examples is not None: + update_fields["code_examples"] = code_examples + + if not update_fields: + return MCPErrorFormatter.format_error( + error_type="validation_error", + message="No fields provided to update", + suggestion="Provide at least one field to update", + ) + async with httpx.AsyncClient(timeout=timeout) as client: response = await client.put( urljoin(api_url, f"/api/tasks/{task_id}"), json=update_fields From 97a280461a909c233ef5a8c64a61574b1802186c Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Thu, 21 Aug 2025 00:29:36 -0400 Subject: [PATCH 06/12] - Update the Claude and other files based on the changes to the MCP Server. - Implement Assignment type ahead, allow freeform assignee for flexibility. --- CLAUDE.md | 5 +- .../project-tasks/AssigneeTypeaheadInput.tsx | 213 ++++++++++++++++++ .../project-tasks/EditTaskModal.tsx | 27 ++- .../project-tasks/TaskInputComponents.tsx | 5 +- .../project-tasks/TaskTableView.tsx | 43 +++- .../src/components/project-tasks/TasksTab.tsx | 83 ++++++- .../components/settings/IDEGlobalRules.tsx | 46 ++-- archon-ui-main/src/lib/projectSchemas.ts | 4 +- 8 files changed, 371 insertions(+), 55 deletions(-) create mode 100644 archon-ui-main/src/components/project-tasks/AssigneeTypeaheadInput.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 46688916..0bb3b794 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -265,9 +265,10 @@ When connected to Cursor/Windsurf: - `archon:perform_rag_query` - Search knowledge base - `archon:search_code_examples` - Find code snippets -- `archon:manage_project` - Project operations -- `archon:manage_task` - Task management +- `archon:create_project`, `archon:list_projects`, `archon:get_project`, `archon:update_project`, `archon:delete_project` - Project operations +- `archon:create_task`, `archon:list_tasks`, `archon:get_task`, `archon:update_task`, `archon:delete_task` - Task management - `archon:get_available_sources` - List knowledge sources +- `archon:get_project_features` - Get project features ## Important Notes diff --git a/archon-ui-main/src/components/project-tasks/AssigneeTypeaheadInput.tsx b/archon-ui-main/src/components/project-tasks/AssigneeTypeaheadInput.tsx new file mode 100644 index 00000000..06a2915b --- /dev/null +++ b/archon-ui-main/src/components/project-tasks/AssigneeTypeaheadInput.tsx @@ -0,0 +1,213 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { User, Bot, Code, Shield, CheckCircle } from 'lucide-react'; + +interface AssigneeTypeaheadInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + onKeyPress?: (e: React.KeyboardEvent) => void; + autoFocus?: boolean; +} + +// Default assignee options with icons +const DEFAULT_ASSIGNEES = [ + { value: 'User', icon: User, color: 'text-blue-500' }, + { value: 'Archon', icon: Bot, color: 'text-pink-500' }, + { value: 'AI IDE Agent', icon: Code, color: 'text-emerald-500' }, + { value: 'IDE Agent', icon: Code, color: 'text-emerald-500' }, + { value: 'prp-executor', icon: Shield, color: 'text-purple-500' }, + { value: 'prp-validator', icon: CheckCircle, color: 'text-cyan-500' } +]; + +export const AssigneeTypeaheadInput: React.FC = ({ + value, + onChange, + placeholder = 'Type or select assignee...', + className = '', + onKeyPress, + autoFocus = false +}) => { + const [inputValue, setInputValue] = useState(value); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [filteredOptions, setFilteredOptions] = useState(DEFAULT_ASSIGNEES); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + // Update input value when prop changes + useEffect(() => { + setInputValue(value); + }, [value]); + + // Filter options based on input + useEffect(() => { + const filtered = inputValue.trim() === '' + ? DEFAULT_ASSIGNEES + : DEFAULT_ASSIGNEES.filter(option => + option.value.toLowerCase().includes(inputValue.toLowerCase()) + ); + + // Add current input as an option if it's not in the default list and not empty + if (inputValue.trim() && !DEFAULT_ASSIGNEES.find(opt => opt.value.toLowerCase() === inputValue.toLowerCase())) { + filtered.push({ + value: inputValue, + icon: User, + color: 'text-gray-500' + }); + } + + setFilteredOptions(filtered); + setHighlightedIndex(0); + }, [inputValue]); + + // Handle clicking outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + setIsOpen(true); + }; + + const handleInputFocus = () => { + setIsOpen(true); + }; + + const handleInputBlur = () => { + // Delay to allow click on dropdown item + setTimeout(() => { + // Only trigger onChange if the value actually changed + if (inputValue !== value) { + onChange(inputValue); + } + setIsOpen(false); + }, 200); + }; + + const selectOption = useCallback((optionValue: string) => { + setInputValue(optionValue); + onChange(optionValue); + setIsOpen(false); + inputRef.current?.focus(); + }, [onChange]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + setIsOpen(true); + e.preventDefault(); + return; + } + + if (!isOpen) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex(prev => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex(prev => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ); + break; + case 'Enter': + e.preventDefault(); + if (filteredOptions[highlightedIndex]) { + selectOption(filteredOptions[highlightedIndex].value); + } + break; + case 'Escape': + e.preventDefault(); + setIsOpen(false); + break; + case 'Tab': + if (filteredOptions[highlightedIndex]) { + selectOption(filteredOptions[highlightedIndex].value); + } + break; + } + }; + + const handleKeyPressWrapper = (e: React.KeyboardEvent) => { + // Don't trigger the parent's Enter handler if dropdown is open + if (e.key === 'Enter' && isOpen && filteredOptions.length > 0) { + e.preventDefault(); + e.stopPropagation(); + return; + } + onKeyPress?.(e); + }; + + return ( +
+ + + {isOpen && filteredOptions.length > 0 && ( +
+ {filteredOptions.map((option, index) => { + const Icon = option.icon; + const isHighlighted = index === highlightedIndex; + + return ( +
selectOption(option.value)} + className={` + flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors + ${isHighlighted + ? 'bg-cyan-100 dark:bg-cyan-900/30' + : 'hover:bg-gray-100 dark:hover:bg-gray-800' + } + `} + onMouseEnter={() => setHighlightedIndex(index)} + > + + + {option.value} + + {option.value === inputValue && ( + + current + + )} +
+ ); + })} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/archon-ui-main/src/components/project-tasks/EditTaskModal.tsx b/archon-ui-main/src/components/project-tasks/EditTaskModal.tsx index 6bb5f718..3883a0e5 100644 --- a/archon-ui-main/src/components/project-tasks/EditTaskModal.tsx +++ b/archon-ui-main/src/components/project-tasks/EditTaskModal.tsx @@ -2,7 +2,7 @@ import React, { memo, useCallback, useMemo, useState, useEffect, useRef } from ' import { X } from 'lucide-react'; import { Button } from '../ui/Button'; import { ArchonLoadingSpinner } from '../animations/Animations'; -import { DebouncedInput, FeatureInput } from './TaskInputComponents'; +import { DebouncedInput, FeatureInput, AssigneeTypeaheadInput } from './TaskInputComponents'; import type { Task } from './TaskTableView'; interface EditTaskModalProps { @@ -16,7 +16,15 @@ interface EditTaskModalProps { getTasksForPrioritySelection: (status: Task['status']) => Array<{value: number, label: string}>; } -const ASSIGNEE_OPTIONS = ['User', 'Archon', 'AI IDE Agent'] as const; +// Assignee options - expanded to include all agent types +const ASSIGNEE_OPTIONS = [ + 'User', + 'Archon', + 'AI IDE Agent', + 'IDE Agent', + 'prp-executor', + 'prp-validator' +] as const; // Removed debounce utility - now using DebouncedInput component @@ -82,10 +90,10 @@ export const EditTaskModal = memo(({ setLocalTask(prev => prev ? { ...prev, task_order: parseInt(e.target.value) } : null); }, []); - const handleAssigneeChange = useCallback((e: React.ChangeEvent) => { + const handleAssigneeChange = useCallback((value: string) => { setLocalTask(prev => prev ? { ...prev, - assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' } + assignee: { name: value, avatar: '' } } : null); }, []); @@ -167,15 +175,12 @@ export const EditTaskModal = memo(({
- + />
diff --git a/archon-ui-main/src/components/project-tasks/TaskInputComponents.tsx b/archon-ui-main/src/components/project-tasks/TaskInputComponents.tsx index e1a136e3..095502d4 100644 --- a/archon-ui-main/src/components/project-tasks/TaskInputComponents.tsx +++ b/archon-ui-main/src/components/project-tasks/TaskInputComponents.tsx @@ -169,4 +169,7 @@ export const FeatureInput = memo(({ prevProps.projectFeatures === nextProps.projectFeatures; }); -FeatureInput.displayName = 'FeatureInput'; \ No newline at end of file +FeatureInput.displayName = 'FeatureInput'; + +// Re-export AssigneeTypeaheadInput for convenience +export { AssigneeTypeaheadInput } from './AssigneeTypeaheadInput'; \ 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 ace50d66..e6afe965 100644 --- a/archon-ui-main/src/components/project-tasks/TaskTableView.tsx +++ b/archon-ui-main/src/components/project-tasks/TaskTableView.tsx @@ -7,6 +7,7 @@ import { projectService } from '../../services/projectService'; import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils'; import { DraggableTaskCard } from './DraggableTaskCard'; import { copyToClipboard } from '../../utils/clipboard'; +import { AssigneeTypeaheadInput } from './TaskInputComponents'; export interface Task { id: string; @@ -79,7 +80,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' | 'typeahead'; options?: string[]; placeholder?: string; isEditing: boolean; @@ -140,7 +141,37 @@ const EditableCell = ({ return (
- {type === 'select' ? ( + {type === 'typeahead' ? ( +
+ { + setEditValue(value); + // Update the value but don't auto-save yet + }} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } + }} + placeholder={placeholder} + className="w-full 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 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]" + autoFocus + /> + {/* Save button for explicit save */} + +
+ ) : type === 'select' ? ( setNewTask(prev => ({ + onChange={(value) => setNewTask(prev => ({ ...prev, - assignee: { name: e.target.value || 'AI IDE Agent', avatar: '' } + assignee: { name: value || 'AI IDE Agent', avatar: '' } }))} onKeyPress={handleKeyPress} placeholder="AI IDE Agent" diff --git a/archon-ui-main/src/components/project-tasks/TasksTab.tsx b/archon-ui-main/src/components/project-tasks/TasksTab.tsx index 288ff265..a2918d13 100644 --- a/archon-ui-main/src/components/project-tasks/TasksTab.tsx +++ b/archon-ui-main/src/components/project-tasks/TasksTab.tsx @@ -14,8 +14,15 @@ import { TaskTableView, Task } from './TaskTableView'; import { TaskBoardView } from './TaskBoardView'; import { EditTaskModal } from './EditTaskModal'; -// Assignee utilities -const ASSIGNEE_OPTIONS = ['User', 'Archon', 'AI IDE Agent'] as const; +// Assignee utilities - expanded to include all agent types +const ASSIGNEE_OPTIONS = [ + 'User', + 'Archon', + 'AI IDE Agent', + 'IDE Agent', + 'prp-executor', + 'prp-validator' +] as const; // Delete confirmation modal component interface DeleteConfirmModalProps { @@ -140,6 +147,9 @@ export const TasksTab = ({ const [taskToDelete, setTaskToDelete] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + // Track local updates to prevent echo from WebSocket + const [localUpdates, setLocalUpdates] = useState>({}); + // Track recently deleted tasks to prevent race conditions const [recentlyDeletedIds, setRecentlyDeletedIds] = useState>(new Set()); @@ -164,6 +174,21 @@ export const TasksTab = ({ return; } + // Check if this is an echo of a local update + const localUpdateTime = localUpdates[updatedTask.id]; + if (localUpdateTime && Date.now() - localUpdateTime < 2000) { + console.log('[Socket] Skipping echo update for locally updated task:', updatedTask.id); + // Clean up the local update marker after the echo protection window + setTimeout(() => { + setLocalUpdates(prev => { + const newUpdates = { ...prev }; + delete newUpdates[updatedTask.id]; + return newUpdates; + }); + }, 2000); + return; + } + // Skip updates while modal is open for the same task to prevent conflicts if (isModalOpen && editingTask?.id === updatedTask.id) { console.log('[Socket] Skipping update for task being edited:', updatedTask.id); @@ -189,7 +214,7 @@ export const TasksTab = ({ setTimeout(() => onTasksChange(updated), 0); return updated; }); - }, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds]); + }, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds, localUpdates]); const handleTaskCreated = useCallback((message: any) => { const newTask = message.data || message; @@ -534,6 +559,12 @@ export const TasksTab = ({ return updated; }); console.log(`[TasksTab] Optimistically updated UI for task ${taskId}`); + + // Mark this update as local to prevent echo when socket update arrives + setLocalUpdates(prev => ({ + ...prev, + [taskId]: Date.now() + })); try { // Then update the backend @@ -555,6 +586,13 @@ export const TasksTab = ({ return updated; }); + // Clear the local update marker + setLocalUpdates(prev => { + const newUpdates = { ...prev }; + delete newUpdates[taskId]; + return newUpdates; + }); + alert(`Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; @@ -655,6 +693,25 @@ export const TasksTab = ({ // Inline task update function const updateTaskInline = async (taskId: string, updates: Partial) => { console.log(`[TasksTab] Inline update for task ${taskId} with updates:`, updates); + + // Store the original task for potential rollback + const originalTask = tasks.find(t => t.id === taskId); + + // Optimistically update the UI immediately + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId + ? { ...task, ...updates } + : task + ) + ); + + // Mark this update as local to prevent echo when socket update arrives + setLocalUpdates(prev => ({ + ...prev, + [taskId]: Date.now() + })); + try { const updateData: Partial = {}; @@ -674,11 +731,25 @@ export const TasksTab = ({ await projectService.updateTask(taskId, updateData); console.log(`[TasksTab] projectService.updateTask successful for ${taskId}.`); - // Don't update local state optimistically - let socket handle it - console.log(`[TasksTab] Waiting for socket update for task ${taskId}.`); - } catch (error) { console.error(`[TasksTab] Failed to update task ${taskId} inline:`, error); + + // Revert the optimistic update on error + if (originalTask) { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId ? originalTask : task + ) + ); + } + + // Clear the local update marker + setLocalUpdates(prev => { + const newUpdates = { ...prev }; + delete newUpdates[taskId]; + return newUpdates; + }); + alert(`Failed to update task: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } diff --git a/archon-ui-main/src/components/settings/IDEGlobalRules.tsx b/archon-ui-main/src/components/settings/IDEGlobalRules.tsx index 0221e78c..9f7cb3cd 100644 --- a/archon-ui-main/src/components/settings/IDEGlobalRules.tsx +++ b/archon-ui-main/src/components/settings/IDEGlobalRules.tsx @@ -30,11 +30,11 @@ export const IDEGlobalRules = () => { **MANDATORY: Always complete the full Archon specific task cycle before any coding:** -1. **Check Current Task** → \`archon:manage_task(action="get", task_id="...")\` +1. **Check Current Task** → \`archon:get_task(task_id="...")\` 2. **Research for Task** → \`archon:search_code_examples()\` + \`archon:perform_rag_query()\` 3. **Implement the Task** → Write code based on research -4. **Update Task Status** → \`archon:manage_task(action="update", task_id="...", update_fields={"status": "review"})\` -5. **Get Next Task** → \`archon:manage_task(action="list", filter_by="status", filter_value="todo")\` +4. **Update Task Status** → \`archon:update_task(task_id="...", status="review")\` +5. **Get Next Task** → \`archon:list_tasks(filter_by="status", filter_value="todo")\` 6. **Repeat Cycle** **NEVER skip task updates with the Archon MCP server. NEVER code without checking current tasks first.** @@ -45,8 +45,7 @@ export const IDEGlobalRules = () => { \`\`\`bash # Create project container -archon:manage_project( - action="create", +archon:create_project( title="Descriptive Project Name", github_repo="github.com/user/repo-name" ) @@ -60,7 +59,7 @@ archon:manage_project( # First, analyze existing codebase thoroughly # Read all major files, understand architecture, identify current state # Then create project container -archon:manage_project(action="create", title="Existing Project Name") +archon:create_project(title="Existing Project Name") # Research current tech stack and create tasks for remaining work # Focus on what needs to be built, not what already exists @@ -70,7 +69,7 @@ archon:manage_project(action="create", title="Existing Project Name") \`\`\`bash # Check existing project status -archon:manage_task(action="list", filter_by="project", filter_value="[project_id]") +archon:list_tasks(filter_by="project", filter_value="[project_id]") # Pick up where you left off - no new project creation needed # Continue with standard development iteration workflow @@ -101,16 +100,14 @@ archon:search_code_examples(query="[specific feature] implementation", match_cou \`\`\`bash # Get current project status -archon:manage_task( - action="list", +archon:list_tasks( filter_by="project", filter_value="[project_id]", include_closed=false ) # Get next priority task -archon:manage_task( - action="list", +archon:list_tasks( filter_by="status", filter_value="todo", project_id="[project_id]" @@ -150,15 +147,14 @@ archon:search_code_examples( **1. Get Task Details:** \`\`\`bash -archon:manage_task(action="get", task_id="[current_task_id]") +archon:get_task(task_id="[current_task_id]") \`\`\` **2. Update to In-Progress:** \`\`\`bash -archon:manage_task( - action="update", +archon:update_task( task_id="[current_task_id]", - update_fields={"status": "doing"} + status="doing" ) \`\`\` @@ -170,10 +166,9 @@ archon:manage_task( **4. Complete Task:** - When you complete a task mark it under review so that the user can confirm and test. \`\`\`bash -archon:manage_task( - action="update", +archon:update_task( task_id="[current_task_id]", - update_fields={"status": "review"} + status="review" ) \`\`\` @@ -225,7 +220,7 @@ archon:search_code_examples(query="PostgreSQL connection pooling Node.js", match **Start of each coding session:** 1. Check available sources: \`archon:get_available_sources()\` -2. Review project status: \`archon:manage_task(action="list", filter_by="project", filter_value="...")\` +2. Review project status: \`archon:list_tasks(filter_by="project", filter_value="...")\` 3. Identify next priority task: Find highest \`task_order\` in "todo" status 4. Conduct task-specific research 5. Begin implementation @@ -247,17 +242,15 @@ archon:search_code_examples(query="PostgreSQL connection pooling Node.js", match **Status Update Examples:** \`\`\`bash # Move to review when implementation complete but needs testing -archon:manage_task( - action="update", +archon:update_task( task_id="...", - update_fields={"status": "review"} + status="review" ) # Complete task after review passes -archon:manage_task( - action="update", +archon:update_task( task_id="...", - update_fields={"status": "done"} + status="done" ) \`\`\` @@ -291,8 +284,7 @@ archon:manage_task( archon:get_project_features(project_id="...") # Create tasks aligned with features -archon:manage_task( - action="create", +archon:create_task( project_id="...", title="...", feature="Authentication", # Align with project features diff --git a/archon-ui-main/src/lib/projectSchemas.ts b/archon-ui-main/src/lib/projectSchemas.ts index 85192c8b..7e7fe82e 100644 --- a/archon-ui-main/src/lib/projectSchemas.ts +++ b/archon-ui-main/src/lib/projectSchemas.ts @@ -6,8 +6,8 @@ export const UITaskStatusSchema = z.enum(['backlog', 'in-progress', 'review', 'c export const TaskPrioritySchema = z.enum(['low', 'medium', 'high', 'critical']); export const ProjectColorSchema = z.enum(['cyan', 'purple', 'pink', 'blue', 'orange', 'green']); -// Assignee schema - simplified to predefined options -export const AssigneeSchema = z.enum(['User', 'Archon', 'AI IDE Agent']); +// Assignee schema - allow any string value (backend no longer restricts this) +export const AssigneeSchema = z.string(); // Project schemas export const CreateProjectSchema = z.object({ From a194ec9a74cd940513a220dbf4ad7086b2df8939 Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Thu, 21 Aug 2025 01:25:15 -0400 Subject: [PATCH 07/12] - Update consistent Delete Confirmation Modal --- .../src/components/mcp/MCPClients.tsx | 45 ++++++-- .../src/components/project-tasks/DocsTab.tsx | 106 ++++++++++++++---- .../components/project-tasks/DocumentCard.tsx | 4 +- .../project-tasks/TaskBoardView.tsx | 2 +- .../project-tasks/TaskTableView.tsx | 2 +- .../src/components/project-tasks/TasksTab.tsx | 50 ++++++--- .../src/components/ui/DeleteConfirmModal.tsx | 83 ++++++++++++++ .../src/pages/KnowledgeBasePage.tsx | 38 ++++++- archon-ui-main/src/pages/ProjectPage.tsx | 74 +----------- 9 files changed, 275 insertions(+), 129 deletions(-) create mode 100644 archon-ui-main/src/components/ui/DeleteConfirmModal.tsx diff --git a/archon-ui-main/src/components/mcp/MCPClients.tsx b/archon-ui-main/src/components/mcp/MCPClients.tsx index 328832d5..10ed92bd 100644 --- a/archon-ui-main/src/components/mcp/MCPClients.tsx +++ b/archon-ui-main/src/components/mcp/MCPClients.tsx @@ -5,7 +5,7 @@ import { ToolTestingPanel } from './ToolTestingPanel'; import { Button } from '../ui/Button'; import { mcpClientService, MCPClient, MCPClientConfig } from '../../services/mcpClientService'; import { useToast } from '../../contexts/ToastContext'; -import { DeleteConfirmModal } from '../../pages/ProjectPage'; +import { DeleteConfirmModal } from '../ui/DeleteConfirmModal'; // Client interface (keeping for backward compatibility) export interface Client { @@ -710,18 +710,31 @@ const EditClientDrawer: React.FC = ({ client, isOpen, onC } }; - const handleDelete = async () => { - if (confirm(`Are you sure you want to delete "${client.name}"?`)) { - try { - await mcpClientService.deleteClient(client.id); - onClose(); - // Trigger a reload of the clients list - window.location.reload(); - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to delete client'); - } + const handleDelete = () => { + setClientToDelete(client); + setShowDeleteConfirm(true); + }; + + const confirmDeleteClient = async () => { + if (!clientToDelete) return; + + try { + await mcpClientService.deleteClient(clientToDelete.id); + onClose(); + // Trigger a reload of the clients list + window.location.reload(); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to delete client'); + } finally { + setShowDeleteConfirm(false); + setClientToDelete(null); } }; + + const cancelDeleteClient = () => { + setShowDeleteConfirm(false); + setClientToDelete(null); + }; if (!isOpen) return null; @@ -853,6 +866,16 @@ const EditClientDrawer: React.FC = ({ client, isOpen, onC
+ + {/* Delete Confirmation Modal */} + {showDeleteConfirm && clientToDelete && ( + + )}
); }; \ No newline at end of file diff --git a/archon-ui-main/src/components/project-tasks/DocsTab.tsx b/archon-ui-main/src/components/project-tasks/DocsTab.tsx index 075ebb89..4108f5aa 100644 --- a/archon-ui-main/src/components/project-tasks/DocsTab.tsx +++ b/archon-ui-main/src/components/project-tasks/DocsTab.tsx @@ -14,6 +14,7 @@ import { MilkdownEditor } from './MilkdownEditor'; import { VersionHistoryModal } from './VersionHistoryModal'; import { PRPViewer } from '../prp'; import { DocumentCard, NewDocumentCard } from './DocumentCard'; +import { DeleteConfirmModal } from '../ui/DeleteConfirmModal'; @@ -514,6 +515,10 @@ export const DocsTab = ({ // Document state const [documents, setDocuments] = useState([]); const [selectedDocument, setSelectedDocument] = useState(null); + + // Delete confirmation modal state + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [documentToDelete, setDocumentToDelete] = useState<{ id: string; title: string } | null>(null); const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); const [loading, setLoading] = useState(false); @@ -575,7 +580,14 @@ export const DocsTab = ({ document_type: doc.document_type || 'document' })); - setDocuments(projectDocuments); + // Merge with existing documents, preserving any temporary documents + setDocuments(prev => { + // Keep any temporary documents (ones with temp- prefix) + const tempDocs = prev.filter(doc => doc.id.startsWith('temp-')); + + // Merge temporary docs with loaded docs + return [...projectDocuments, ...tempDocs]; + }); // Auto-select first document if available and no document is currently selected if (projectDocuments.length > 0 && !selectedDocument) { @@ -598,6 +610,26 @@ export const DocsTab = ({ const template = DOCUMENT_TEMPLATES[templateKey as keyof typeof DOCUMENT_TEMPLATES]; if (!template) return; + // Create a temporary document for optimistic update + const tempDocument: ProjectDoc = { + id: `temp-${Date.now()}`, + title: template.name, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + content: template.content, + document_type: template.document_type + }; + + // Optimistically add the document to the UI immediately + console.log('[DocsTab] Adding temporary document:', tempDocument); + setDocuments(prev => { + const updated = [...prev, tempDocument]; + console.log('[DocsTab] Documents after optimistic add:', updated); + return updated; + }); + setSelectedDocument(tempDocument); + setShowTemplateModal(false); + try { setIsSaving(true); @@ -608,15 +640,22 @@ export const DocsTab = ({ document_type: template.document_type }); - // Add to documents list - setDocuments(prev => [...prev, newDocument]); + // Replace temporary document with the real one + setDocuments(prev => prev.map(doc => + doc.id === tempDocument.id ? newDocument : doc + )); setSelectedDocument(newDocument); console.log('Document created successfully via API:', newDocument); showToast('Document created successfully', 'success'); - setShowTemplateModal(false); } catch (error) { console.error('Failed to create document:', error); + + // Remove the temporary document on error + setDocuments(prev => prev.filter(doc => doc.id !== tempDocument.id)); + setSelectedDocument(null); + setShowTemplateModal(true); // Re-open the modal + showToast( error instanceof Error ? error.message : 'Failed to create document', 'error' @@ -783,6 +822,34 @@ export const DocsTab = ({ } }; + // Delete confirmation handlers + const confirmDeleteDocument = async () => { + if (!documentToDelete || !project?.id) return; + + try { + // Call API to delete from database first + await projectService.deleteDocument(project.id, documentToDelete.id); + + // Then remove from local state + setDocuments(prev => prev.filter(d => d.id !== documentToDelete.id)); + if (selectedDocument?.id === documentToDelete.id) { + setSelectedDocument(documents.find(d => d.id !== documentToDelete.id) || null); + } + showToast('Document deleted', 'success'); + } catch (error) { + console.error('Failed to delete document:', error); + showToast('Failed to delete document', 'error'); + } finally { + setShowDeleteConfirm(false); + setDocumentToDelete(null); + } + }; + + const cancelDeleteDocument = () => { + setShowDeleteConfirm(false); + setDocumentToDelete(null); + }; + const handleProgressComplete = (data: CrawlProgressData) => { console.log('Crawl completed:', data); setProgressItems(prev => prev.filter(item => item.progressId !== data.progressId)); @@ -935,22 +1002,11 @@ export const DocsTab = ({ document={doc} isActive={selectedDocument?.id === doc.id} onSelect={setSelectedDocument} - onDelete={async (docId) => { - if (!project?.id) return; - - try { - // Call API to delete from database first - await projectService.deleteDocument(project.id, docId); - - // Then remove from local state - setDocuments(prev => prev.filter(d => d.id !== docId)); - if (selectedDocument?.id === docId) { - setSelectedDocument(documents.find(d => d.id !== docId) || null); - } - showToast('Document deleted', 'success'); - } catch (error) { - console.error('Failed to delete document:', error); - showToast('Failed to delete document', 'error'); + onDelete={(docId) => { + const doc = documents.find(d => d.id === docId); + if (doc) { + setDocumentToDelete({ id: docId, title: doc.title }); + setShowDeleteConfirm(true); } }} isDarkMode={isDarkMode} @@ -1099,6 +1155,16 @@ export const DocsTab = ({ }} /> )} + + {/* Delete Confirmation Modal */} + {showDeleteConfirm && documentToDelete && ( + + )} ); }; diff --git a/archon-ui-main/src/components/project-tasks/DocumentCard.tsx b/archon-ui-main/src/components/project-tasks/DocumentCard.tsx index 26966e9b..ef703b8d 100644 --- a/archon-ui-main/src/components/project-tasks/DocumentCard.tsx +++ b/archon-ui-main/src/components/project-tasks/DocumentCard.tsx @@ -117,9 +117,7 @@ export const DocumentCard: React.FC = ({ + + + + + + ); +}; \ No newline at end of file diff --git a/archon-ui-main/src/pages/KnowledgeBasePage.tsx b/archon-ui-main/src/pages/KnowledgeBasePage.tsx index dccc5522..97019644 100644 --- a/archon-ui-main/src/pages/KnowledgeBasePage.tsx +++ b/archon-ui-main/src/pages/KnowledgeBasePage.tsx @@ -18,6 +18,7 @@ import { KnowledgeTable } from '../components/knowledge-base/KnowledgeTable'; import { KnowledgeItemCard } from '../components/knowledge-base/KnowledgeItemCard'; import { GroupedKnowledgeItemCard } from '../components/knowledge-base/GroupedKnowledgeItemCard'; import { KnowledgeGridSkeleton, KnowledgeTableSkeleton } from '../components/knowledge-base/KnowledgeItemSkeleton'; +import { DeleteConfirmModal } from '../components/ui/DeleteConfirmModal'; import { GroupCreationModal } from '../components/knowledge-base/GroupCreationModal'; const extractDomain = (url: string): string => { @@ -70,6 +71,10 @@ export const KnowledgeBasePage = () => { const [isSelectionMode, setIsSelectionMode] = useState(false); const [lastSelectedIndex, setLastSelectedIndex] = useState(null); + // Delete confirmation modal state + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [itemsToDelete, setItemsToDelete] = useState<{ count: number; items: Set } | null>(null); + const { showToast } = useToast(); // Single consolidated loading function - only loads data, no filtering @@ -360,32 +365,43 @@ export const KnowledgeBasePage = () => { if (selectedItems.size === 0) return; const count = selectedItems.size; - const confirmed = window.confirm(`Are you sure you want to delete ${count} selected item${count > 1 ? 's' : ''}?`); - - if (!confirmed) return; + setItemsToDelete({ count, items: new Set(selectedItems) }); + setShowDeleteConfirm(true); + }; + + const confirmDeleteItems = async () => { + if (!itemsToDelete) return; try { // Delete each selected item - const deletePromises = Array.from(selectedItems).map(itemId => + const deletePromises = Array.from(itemsToDelete.items).map(itemId => knowledgeBaseService.deleteKnowledgeItem(itemId) ); await Promise.all(deletePromises); // Remove deleted items from state - setKnowledgeItems(prev => prev.filter(item => !selectedItems.has(item.id))); + setKnowledgeItems(prev => prev.filter(item => !itemsToDelete.items.has(item.id))); // Clear selection setSelectedItems(new Set()); setIsSelectionMode(false); - showToast(`Successfully deleted ${count} item${count > 1 ? 's' : ''}`, 'success'); + showToast(`Successfully deleted ${itemsToDelete.count} item${itemsToDelete.count > 1 ? 's' : ''}`, 'success'); } catch (error) { console.error('Failed to delete selected items:', error); showToast('Failed to delete some items', 'error'); + } finally { + setShowDeleteConfirm(false); + setItemsToDelete(null); } }; + const cancelDeleteItems = () => { + setShowDeleteConfirm(false); + setItemsToDelete(null); + }; + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1194,6 +1210,16 @@ export const KnowledgeBasePage = () => { }} /> )} + + {/* Delete Confirmation Modal */} + {showDeleteConfirm && itemsToDelete && ( + 1 ? 's' : ''}`} + onConfirm={confirmDeleteItems} + onCancel={cancelDeleteItems} + type="knowledge-items" + /> + )} ; }; diff --git a/archon-ui-main/src/pages/ProjectPage.tsx b/archon-ui-main/src/pages/ProjectPage.tsx index f65b360a..fe4b1d19 100644 --- a/archon-ui-main/src/pages/ProjectPage.tsx +++ b/archon-ui-main/src/pages/ProjectPage.tsx @@ -10,6 +10,7 @@ import { TasksTab } from '../components/project-tasks/TasksTab'; import { Button } from '../components/ui/Button'; import { ChevronRight, ShoppingCart, Code, Briefcase, Layers, Plus, X, AlertCircle, Loader2, Heart, BarChart3, Trash2, Pin, ListTodo, Activity, CheckCircle2, Clipboard } from 'lucide-react'; import { copyToClipboard } from '../utils/clipboard'; +import { DeleteConfirmModal } from '../components/ui/DeleteConfirmModal'; // Import our service layer and types import { projectService } from '../services/projectService'; @@ -1062,76 +1063,3 @@ export function ProjectPage({ ); } -// Reusable Delete Confirmation Modal Component -export interface DeleteConfirmModalProps { - itemName: string; - onConfirm: () => void; - onCancel: () => void; - type: 'project' | 'task' | 'client'; -} - -export const DeleteConfirmModal: React.FC = ({ itemName, onConfirm, onCancel, type }) => { - const getTitle = () => { - switch (type) { - case 'project': return 'Delete Project'; - case 'task': return 'Delete Task'; - case 'client': return 'Delete MCP Client'; - } - }; - - const getMessage = () => { - switch (type) { - case 'project': return `Are you sure you want to delete the "${itemName}" project? This will also delete all associated tasks and documents and cannot be undone.`; - case 'task': return `Are you sure you want to delete the "${itemName}" task? This action cannot be undone.`; - case 'client': return `Are you sure you want to delete the "${itemName}" client? This will permanently remove its configuration and cannot be undone.`; - } - }; - - return ( -
-
- -
-
-
- -
-
-

- {getTitle()} -

-

- This action cannot be undone -

-
-
- -

- {getMessage()} -

- -
- - -
-
-
-
- ); -}; \ No newline at end of file From 116e0ada1d06916b6fa5a393381c95eed9bd0a9f Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Thu, 21 Aug 2025 01:26:06 -0400 Subject: [PATCH 08/12] - delete confirmation modal update --- .../src/components/project-tasks/TasksTab.tsx | 69 +------------------ 1 file changed, 3 insertions(+), 66 deletions(-) diff --git a/archon-ui-main/src/components/project-tasks/TasksTab.tsx b/archon-ui-main/src/components/project-tasks/TasksTab.tsx index 9f65b5e2..73bed79c 100644 --- a/archon-ui-main/src/components/project-tasks/TasksTab.tsx +++ b/archon-ui-main/src/components/project-tasks/TasksTab.tsx @@ -13,6 +13,7 @@ import { WebSocketState } from '../../services/socketIOService'; import { TaskTableView, Task } from './TaskTableView'; import { TaskBoardView } from './TaskBoardView'; import { EditTaskModal } from './EditTaskModal'; +import { DeleteConfirmModal } from '../ui/DeleteConfirmModal'; // Assignee utilities - expanded to include all agent types const ASSIGNEE_OPTIONS = [ @@ -25,69 +26,6 @@ const ASSIGNEE_OPTIONS = [ ] as const; // Delete confirmation modal component -interface DeleteConfirmModalProps { - onConfirm: () => void; - onCancel: () => void; - title: string; - message: string; - confirmText?: string; -} - -const DeleteConfirmModal = ({ - onConfirm, - onCancel, - title, - message, - confirmText = 'Archive' -}: DeleteConfirmModalProps) => { - return ( -
-
- -
-
-
- -
-
-

- {title} -

-

- This action cannot be undone -

-
-
- -

- {message} -

- -
- - -
-
-
-
- ); -}; // Mapping functions for status conversion const mapUIStatusToDBStatus = (uiStatus: Task['status']): DatabaseTaskStatus => { @@ -927,14 +865,13 @@ export const TasksTab = ({ {/* Delete Confirmation Modal */} {showDeleteConfirm && taskToDelete && ( { setTaskToDelete(null); setShowDeleteConfirm(false); }} - title="Archive Task" - message={`Are you sure you want to archive the task "${taskToDelete.title}"? You can restore it from the archived tasks view.`} - confirmText="Archive Task" + type="task" /> )} From a549af726f7fd30931c8f042fbe7b617601579c4 Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Thu, 21 Aug 2025 02:07:16 -0400 Subject: [PATCH 09/12] Fixing optimistic updates when switching tabs --- .../src/components/project-tasks/DocsTab.tsx | 8 +- .../src/components/project-tasks/TasksTab.tsx | 176 ++++++++++++------ 2 files changed, 124 insertions(+), 60 deletions(-) diff --git a/archon-ui-main/src/components/project-tasks/DocsTab.tsx b/archon-ui-main/src/components/project-tasks/DocsTab.tsx index 4108f5aa..1b30e7cd 100644 --- a/archon-ui-main/src/components/project-tasks/DocsTab.tsx +++ b/archon-ui-main/src/components/project-tasks/DocsTab.tsx @@ -640,10 +640,10 @@ export const DocsTab = ({ document_type: template.document_type }); - // Replace temporary document with the real one - setDocuments(prev => prev.map(doc => - doc.id === tempDocument.id ? newDocument : doc - )); + // Force refresh to get the real document from server + await loadProjectDocuments(); + + // Select the newly created document setSelectedDocument(newDocument); console.log('Document created successfully via API:', newDocument); diff --git a/archon-ui-main/src/components/project-tasks/TasksTab.tsx b/archon-ui-main/src/components/project-tasks/TasksTab.tsx index 73bed79c..6a24f838 100644 --- a/archon-ui-main/src/components/project-tasks/TasksTab.tsx +++ b/archon-ui-main/src/components/project-tasks/TasksTab.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Table, LayoutGrid, Plus, Wifi, WifiOff, List, Trash2 } from 'lucide-react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; @@ -8,6 +8,7 @@ import { getGlobalOperationTracker } from '../../utils/operationTracker'; import { Card } from '../ui/card'; import { useTaskSocket } from '../../hooks/useTaskSocket'; +import { useOptimisticUpdates } from '../../hooks/useOptimisticUpdates'; import type { CreateTaskRequest, UpdateTaskRequest, DatabaseTaskStatus } from '../../types/project'; import { WebSocketState } from '../../services/socketIOService'; import { TaskTableView, Task } from './TaskTableView'; @@ -85,16 +86,26 @@ export const TasksTab = ({ const [taskToDelete, setTaskToDelete] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - // Track local updates to prevent echo from WebSocket - const [localUpdates, setLocalUpdates] = useState>({}); + // Use optimistic updates hook for proper echo suppression + const { addPendingUpdate, isPendingUpdate, removePendingUpdate } = useOptimisticUpdates(); // Track recently deleted tasks to prevent race conditions const [recentlyDeletedIds, setRecentlyDeletedIds] = useState>(new Set()); - // Initialize tasks + // Track the project ID to detect when we switch projects + const lastProjectId = useRef(projectId); + + // Initialize tasks when component mounts or project changes useEffect(() => { - setTasks(initialTasks); - }, [initialTasks]); + // If project changed, always reinitialize + if (lastProjectId.current !== projectId) { + setTasks(initialTasks); + lastProjectId.current = projectId; + } else if (tasks.length === 0 && initialTasks.length > 0) { + // Only initialize if we have no tasks but received initial tasks + setTasks(initialTasks); + } + }, [initialTasks, projectId]); // Load project features on component mount useEffect(() => { @@ -113,19 +124,8 @@ export const TasksTab = ({ } // Check if this is an echo of a local update - const localUpdateTime = localUpdates[updatedTask.id]; - console.log(`[Socket] Checking for echo - Task ${updatedTask.id}, localUpdateTime: ${localUpdateTime}, current time: ${Date.now()}, diff: ${localUpdateTime ? Date.now() - localUpdateTime : 'N/A'}`); - - if (localUpdateTime && Date.now() - localUpdateTime < 5000) { // Increased window to 5 seconds + if (isPendingUpdate(updatedTask.id, mappedTask)) { console.log('[Socket] Skipping echo update for locally updated task:', updatedTask.id); - // Clean up the local update marker after the echo protection window - setTimeout(() => { - setLocalUpdates(prev => { - const newUpdates = { ...prev }; - delete newUpdates[updatedTask.id]; - return newUpdates; - }); - }, 5000); return; } console.log('[Socket] Not an echo, applying update for task:', updatedTask.id); @@ -155,7 +155,7 @@ export const TasksTab = ({ setTimeout(() => onTasksChange(updated), 0); return updated; }); - }, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds, localUpdates]); + }, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds, isPendingUpdate]); const handleTaskCreated = useCallback((message: any) => { const newTask = message.data || message; @@ -286,6 +286,30 @@ export const TasksTab = ({ setEditingTask(task); setIsSavingTask(true); + + // Store original task for rollback + const originalTask = task.id ? tasks.find(t => t.id === task.id) : null; + + // OPTIMISTIC UPDATE: Update UI immediately for existing tasks + if (task.id) { + setTasks(prev => { + const updated = prev.map(t => + t.id === task.id ? task : t + ); + // Notify parent of the change + onTasksChange(updated); + return updated; + }); + + // Mark as pending update to prevent echo + addPendingUpdate({ + id: task.id, + timestamp: Date.now(), + data: task, + operation: 'update' + }); + } + try { let parentTaskId = task.id; @@ -323,6 +347,22 @@ export const TasksTab = ({ closeModal(); } catch (error) { console.error('Failed to save task:', error); + + // Rollback optimistic update on error + if (task.id && originalTask) { + setTasks(prev => { + const updated = prev.map(t => + t.id === task.id ? originalTask : t + ); + // Notify parent of the rollback + onTasksChange(updated); + return updated; + }); + + // Clear pending update tracking + removePendingUpdate(task.id); + } + alert(`Failed to save task: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsSavingTask(false); @@ -503,17 +543,17 @@ export const TasksTab = ({ }); console.log(`[TasksTab] Optimistically updated UI for task ${taskId}`); - // Mark this update as local to prevent echo when socket update arrives - const updateTime = Date.now(); - console.log(`[TasksTab] Marking update as local for task ${taskId} at time ${updateTime}`); - setLocalUpdates(prev => { - const newUpdates = { - ...prev, - [taskId]: updateTime - }; - console.log('[TasksTab] LocalUpdates state:', newUpdates); - return newUpdates; - }); + // Mark as pending update to prevent echo when socket update arrives + const taskToUpdate = tasks.find(t => t.id === taskId); + if (taskToUpdate) { + const updatedTask = { ...taskToUpdate, status: newStatus, task_order: newOrder }; + addPendingUpdate({ + id: taskId, + timestamp: Date.now(), + data: updatedTask, + operation: 'update' + }); + } try { // Then update the backend @@ -535,12 +575,8 @@ export const TasksTab = ({ return updated; }); - // Clear the local update marker - setLocalUpdates(prev => { - const newUpdates = { ...prev }; - delete newUpdates[taskId]; - return newUpdates; - }); + // Clear the pending update marker + removePendingUpdate(taskId); alert(`Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`); } @@ -613,10 +649,25 @@ export const TasksTab = ({ // Inline task creation function const createTaskInline = async (newTask: Omit) => { + // Create temporary task with a temp ID for optimistic update + const tempId = `temp-${Date.now()}`; + try { // Auto-assign next order number if not provided const nextOrder = newTask.task_order || getNextOrderForStatus(newTask.status); + const tempTask: Task = { + ...newTask, + id: tempId, + task_order: nextOrder + }; + + // OPTIMISTIC UPDATE: Add to UI immediately + setTasks(prev => [...prev, tempTask]); + + // Notify parent component of the change + onTasksChange([...tasks, tempTask]); + const createData: CreateTaskRequest = { project_id: projectId, title: newTask.title, @@ -628,13 +679,30 @@ export const TasksTab = ({ ...(newTask.featureColor && { featureColor: newTask.featureColor }) }; - await projectService.createTask(createData); + const createdTask = await projectService.createTask(createData); - // Don't reload tasks - let socket updates handle synchronization - console.log('[TasksTab] Task creation sent to backend, waiting for socket update'); + // Replace temp task with real one + setTasks(prev => { + const updated = prev.map(t => + t.id === tempId ? mapDatabaseTaskToUITask(createdTask) : t + ); + // Notify parent of the update + onTasksChange(updated); + return updated; + }); + + console.log('[TasksTab] Task created successfully with optimistic update'); } catch (error) { console.error('Failed to create task:', error); + + // Rollback: Remove temp task on error + setTasks(prev => { + const updated = prev.filter(t => t.id !== tempId); + onTasksChange(updated); + return updated; + }); + throw error; } }; @@ -660,17 +728,17 @@ export const TasksTab = ({ return updated; }); - // Mark this update as local to prevent echo when socket update arrives - const updateTime = Date.now(); - console.log(`[TasksTab] Marking update as local for task ${taskId} at time ${updateTime}`); - setLocalUpdates(prev => { - const newUpdates = { - ...prev, - [taskId]: updateTime - }; - console.log('[TasksTab] LocalUpdates state:', newUpdates); - return newUpdates; - }); + // Mark as pending update to prevent echo when socket update arrives + const taskToUpdate = tasks.find(t => t.id === taskId); + if (taskToUpdate) { + const updatedTask = { ...taskToUpdate, ...updates }; + addPendingUpdate({ + id: taskId, + timestamp: Date.now(), + data: updatedTask, + operation: 'update' + }); + } try { const updateData: Partial = {}; @@ -703,12 +771,8 @@ export const TasksTab = ({ ); } - // Clear the local update marker - setLocalUpdates(prev => { - const newUpdates = { ...prev }; - delete newUpdates[taskId]; - return newUpdates; - }); + // Clear the pending update marker + removePendingUpdate(taskId); alert(`Failed to update task: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; From f0db9ac3bf74682b2a05dc4e0d63c5e292dcd2cc Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Thu, 21 Aug 2025 03:01:35 -0400 Subject: [PATCH 10/12] Fixing task socket issues when adding tasks in task table. --- .../src/components/project-tasks/TasksTab.tsx | 119 +++++++++--------- archon-ui-main/src/hooks/useTaskSocket.ts | 14 +-- archon-ui-main/src/pages/ProjectPage.tsx | 8 +- archon-ui-main/src/types/project.ts | 8 +- 4 files changed, 74 insertions(+), 75 deletions(-) diff --git a/archon-ui-main/src/components/project-tasks/TasksTab.tsx b/archon-ui-main/src/components/project-tasks/TasksTab.tsx index 6a24f838..41be1a5d 100644 --- a/archon-ui-main/src/components/project-tasks/TasksTab.tsx +++ b/archon-ui-main/src/components/project-tasks/TasksTab.tsx @@ -53,15 +53,15 @@ const mapDBStatusToUIStatus = (dbStatus: DatabaseTaskStatus): Task['status'] => const mapDatabaseTaskToUITask = (dbTask: any): Task => { return { id: dbTask.id, - title: dbTask.title, + title: dbTask.title || '', description: dbTask.description || '', - status: mapDBStatusToUIStatus(dbTask.status), + status: mapDBStatusToUIStatus(dbTask.status || 'todo'), assignee: { name: dbTask.assignee || 'User', avatar: '' }, feature: dbTask.feature || 'General', - featureColor: '#3b82f6', // Default blue color + featureColor: dbTask.featureColor || '#3b82f6', // Default blue color task_order: dbTask.task_order || 0, }; }; @@ -90,7 +90,10 @@ export const TasksTab = ({ const { addPendingUpdate, isPendingUpdate, removePendingUpdate } = useOptimisticUpdates(); // Track recently deleted tasks to prevent race conditions - const [recentlyDeletedIds, setRecentlyDeletedIds] = useState>(new Set()); + const recentlyDeletedIdsRef = useRef>(new Set()); + + // Track recently created tasks to prevent WebSocket echo + const recentlyCreatedIdsRef = useRef>(new Set()); // Track the project ID to detect when we switch projects const lastProjectId = useRef(projectId); @@ -115,10 +118,13 @@ export const TasksTab = ({ // Optimized socket handlers with conflict resolution const handleTaskUpdated = useCallback((message: any) => { const updatedTask = message.data || message; + console.log('📝 Real-time task updated received:', updatedTask); + const mappedTask = mapDatabaseTaskToUITask(updatedTask); + console.log('📝 Mapped task:', mappedTask); // Skip updates for recently deleted tasks (race condition prevention) - if (recentlyDeletedIds.has(updatedTask.id)) { + if (recentlyDeletedIdsRef.current.has(updatedTask.id)) { console.log('[Socket] Ignoring update for recently deleted task:', updatedTask.id); return; } @@ -140,11 +146,14 @@ export const TasksTab = ({ // Use server timestamp for conflict resolution const existingTask = prev.find(task => task.id === updatedTask.id); - // Skip if we already have this task (prevent duplicate additions) if (!existingTask) { - console.log('[Socket] Task not found locally, adding:', updatedTask.id); + console.log('[Socket] Task not found locally, skipping update for:', updatedTask.id); + console.log('[Socket] Current task IDs:', prev.map(t => t.id)); + return prev; } + console.log('[Socket] Updating task from:', existingTask.status, 'to:', mappedTask.status); + const updated = prev.map(task => task.id === updatedTask.id ? { ...mappedTask } @@ -155,36 +164,36 @@ export const TasksTab = ({ setTimeout(() => onTasksChange(updated), 0); return updated; }); - }, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds, isPendingUpdate]); + }, [onTasksChange, isModalOpen, editingTask?.id, isPendingUpdate]); const handleTaskCreated = useCallback((message: any) => { const newTask = message.data || message; console.log('🆕 Real-time task created:', newTask); + + // Skip if this is our own recently created task + if (recentlyCreatedIdsRef.current.has(newTask.id)) { + console.log('[Socket] Skipping echo of our own task creation:', newTask.id); + return; + } + const mappedTask = mapDatabaseTaskToUITask(newTask); setTasks(prev => { - // Check if this is replacing a temporary task from optimistic update - const hasTempTask = prev.some(task => task.id.startsWith('temp-') && task.title === mappedTask.title); - - if (hasTempTask) { - // Replace temporary task with real task - const updated = prev.map(task => - task.id.startsWith('temp-') && task.title === mappedTask.title - ? mappedTask - : task - ); - setTimeout(() => onTasksChange(updated), 0); - console.log('Replaced temporary task with real task:', mappedTask.id); - return updated; - } - // Check if task already exists to prevent duplicates if (prev.some(task => task.id === newTask.id)) { console.log('Task already exists, skipping create'); return prev; } - const updated = [...prev, mappedTask]; + // Remove any temp tasks with same title (in case of race condition) + const filteredPrev = prev.filter(task => { + // Keep non-temp tasks + if (!task.id?.startsWith('temp-')) return true; + // Remove temp tasks with matching title + return task.title !== newTask.title; + }); + + const updated = [...filteredPrev, mappedTask]; setTimeout(() => onTasksChange(updated), 0); return updated; }); @@ -195,11 +204,7 @@ export const TasksTab = ({ console.log('🗑️ Real-time task deleted:', deletedTask); // Remove from recently deleted cache when deletion is confirmed - setRecentlyDeletedIds(prev => { - const newSet = new Set(prev); - newSet.delete(deletedTask.id); - return newSet; - }); + recentlyDeletedIdsRef.current.delete(deletedTask.id); setTasks(prev => { const updated = prev.filter(task => task.id !== deletedTask.id); @@ -234,7 +239,7 @@ export const TasksTab = ({ const initialWebSocketTasks = message.data || message; const uiTasks: Task[] = initialWebSocketTasks.map(mapDatabaseTaskToUITask); setTasks(uiTasks); - onTasksChange(uiTasks); + setTimeout(() => onTasksChange(uiTasks), 0); }, [onTasksChange]); // Simplified socket connection with better lifecycle management @@ -297,7 +302,7 @@ export const TasksTab = ({ t.id === task.id ? task : t ); // Notify parent of the change - onTasksChange(updated); + setTimeout(() => onTasksChange(updated), 0); return updated; }); @@ -355,7 +360,7 @@ export const TasksTab = ({ t.id === task.id ? originalTask : t ); // Notify parent of the rollback - onTasksChange(updated); + setTimeout(() => onTasksChange(updated), 0); return updated; }); @@ -372,7 +377,7 @@ export const TasksTab = ({ // Update tasks helper const updateTasks = (newTasks: Task[]) => { setTasks(newTasks); - onTasksChange(newTasks); + setTimeout(() => onTasksChange(newTasks), 0); }; // Helper function to reorder tasks by status to ensure no gaps (1,2,3...) @@ -598,7 +603,7 @@ export const TasksTab = ({ try { // Add to recently deleted cache to prevent race conditions - setRecentlyDeletedIds(prev => new Set(prev).add(taskToDelete.id)); + recentlyDeletedIdsRef.current.add(taskToDelete.id); // OPTIMISTIC UPDATE: Remove task from UI immediately setTasks(prev => { @@ -614,22 +619,14 @@ export const TasksTab = ({ // Clear from recently deleted cache after a delay (to catch any lingering socket events) setTimeout(() => { - setRecentlyDeletedIds(prev => { - const newSet = new Set(prev); - newSet.delete(taskToDelete.id); - return newSet; - }); + recentlyDeletedIdsRef.current.delete(taskToDelete.id); }, 3000); // 3 second window to ignore stale socket events } catch (error) { console.error('Failed to delete task:', error); // Remove from recently deleted cache on error - setRecentlyDeletedIds(prev => { - const newSet = new Set(prev); - newSet.delete(taskToDelete.id); - return newSet; - }); + recentlyDeletedIdsRef.current.delete(taskToDelete.id); // ROLLBACK on error - restore the task setTasks(prev => { @@ -647,7 +644,7 @@ export const TasksTab = ({ } }; - // Inline task creation function + // Inline task creation function with optimistic update const createTaskInline = async (newTask: Omit) => { // Create temporary task with a temp ID for optimistic update const tempId = `temp-${Date.now()}`; @@ -663,10 +660,11 @@ export const TasksTab = ({ }; // OPTIMISTIC UPDATE: Add to UI immediately - setTasks(prev => [...prev, tempTask]); - - // Notify parent component of the change - onTasksChange([...tasks, tempTask]); + setTasks(prev => { + const updated = [...prev, tempTask]; + setTimeout(() => onTasksChange(updated), 0); + return updated; + }); const createData: CreateTaskRequest = { project_id: projectId, @@ -680,14 +678,21 @@ export const TasksTab = ({ }; const createdTask = await projectService.createTask(createData); + const mappedCreatedTask = mapDatabaseTaskToUITask(createdTask); + + // Add to recently created to prevent WebSocket echo from duplicating + recentlyCreatedIdsRef.current.add(createdTask.id); + setTimeout(() => { + recentlyCreatedIdsRef.current.delete(createdTask.id); + }, 5000); // Replace temp task with real one setTasks(prev => { + // Find and replace the temp task const updated = prev.map(t => - t.id === tempId ? mapDatabaseTaskToUITask(createdTask) : t + t.id === tempId ? mappedCreatedTask : t ); - // Notify parent of the update - onTasksChange(updated); + setTimeout(() => onTasksChange(updated), 0); return updated; }); @@ -697,11 +702,7 @@ export const TasksTab = ({ console.error('Failed to create task:', error); // Rollback: Remove temp task on error - setTasks(prev => { - const updated = prev.filter(t => t.id !== tempId); - onTasksChange(updated); - return updated; - }); + setTasks(prev => prev.filter(t => t.id !== tempId)); throw error; } @@ -831,7 +832,7 @@ export const TasksTab = ({
{viewMode === 'table' ? ( t && t.id && t.title !== undefined)} onTaskView={openEditModal} onTaskComplete={completeTask} onTaskDelete={deleteTask} @@ -841,7 +842,7 @@ export const TasksTab = ({ /> ) : ( t && t.id && t.title !== undefined)} onTaskView={openEditModal} onTaskComplete={completeTask} onTaskDelete={deleteTask} diff --git a/archon-ui-main/src/hooks/useTaskSocket.ts b/archon-ui-main/src/hooks/useTaskSocket.ts index 376bb501..b427839a 100644 --- a/archon-ui-main/src/hooks/useTaskSocket.ts +++ b/archon-ui-main/src/hooks/useTaskSocket.ts @@ -98,7 +98,7 @@ export function useTaskSocket(options: UseTaskSocketOptions) { return () => { clearInterval(interval); }; - }, [onConnectionStateChange, memoizedHandlers]); + }, []); // No dependencies - only run once on mount // Initialize connection once and register handlers useEffect(() => { @@ -132,15 +132,7 @@ export function useTaskSocket(options: UseTaskSocketOptions) { initializeConnection(); - }, [projectId, memoizedHandlers]); - - // Update handlers when they change (without reconnecting) - useEffect(() => { - if (isInitializedRef.current && currentProjectIdRef.current === projectId) { - console.log(`[USE_TASK_SOCKET] Updating handlers for component: ${componentIdRef.current}`); - taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers()); - } - }, [memoizedHandlers, projectId]); + }, [projectId]); // Only depend on projectId // Handle project change (different project) useEffect(() => { @@ -176,7 +168,7 @@ export function useTaskSocket(options: UseTaskSocketOptions) { switchProject(); } - }, [projectId, memoizedHandlers]); + }, [projectId]); // Only depend on projectId // Cleanup on unmount useEffect(() => { diff --git a/archon-ui-main/src/pages/ProjectPage.tsx b/archon-ui-main/src/pages/ProjectPage.tsx index fe4b1d19..bc0ebc70 100644 --- a/archon-ui-main/src/pages/ProjectPage.tsx +++ b/archon-ui-main/src/pages/ProjectPage.tsx @@ -367,12 +367,12 @@ export function ProjectPage({ const tasksData = await projectService.getTasksByProject(projectId); - // Convert backend tasks to UI format + // Convert backend tasks to UI format with proper defaults const uiTasks: Task[] = tasksData.map(task => ({ id: task.id, - title: task.title, - description: task.description, - status: (task.uiStatus || 'backlog') as Task['status'], + title: task.title || '', + description: task.description || '', + status: (task.uiStatus || task.status || 'backlog') as Task['status'], assignee: { name: (task.assignee || 'User') as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' diff --git a/archon-ui-main/src/types/project.ts b/archon-ui-main/src/types/project.ts index b4546067..392a918a 100644 --- a/archon-ui-main/src/types/project.ts +++ b/archon-ui-main/src/types/project.ts @@ -195,7 +195,13 @@ export const statusMappings = { export function dbTaskToUITask(dbTask: Task): Task { return { ...dbTask, - uiStatus: statusMappings.dbToUI[dbTask.status] + uiStatus: statusMappings.dbToUI[dbTask.status || 'todo'], + // Ensure all required fields have defaults + title: dbTask.title || '', + description: dbTask.description || '', + assignee: dbTask.assignee || 'User', + feature: dbTask.feature || 'General', + task_order: dbTask.task_order || 0 }; } From 0e425a3b6776a1747d07c57aeff5183fe9efcfe2 Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Thu, 21 Aug 2025 03:03:01 -0400 Subject: [PATCH 11/12] Remove console logs --- .../src/components/project-tasks/TasksTab.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/archon-ui-main/src/components/project-tasks/TasksTab.tsx b/archon-ui-main/src/components/project-tasks/TasksTab.tsx index 41be1a5d..56d686aa 100644 --- a/archon-ui-main/src/components/project-tasks/TasksTab.tsx +++ b/archon-ui-main/src/components/project-tasks/TasksTab.tsx @@ -118,10 +118,7 @@ export const TasksTab = ({ // Optimized socket handlers with conflict resolution const handleTaskUpdated = useCallback((message: any) => { const updatedTask = message.data || message; - console.log('📝 Real-time task updated received:', updatedTask); - const mappedTask = mapDatabaseTaskToUITask(updatedTask); - console.log('📝 Mapped task:', mappedTask); // Skip updates for recently deleted tasks (race condition prevention) if (recentlyDeletedIdsRef.current.has(updatedTask.id)) { @@ -134,7 +131,6 @@ export const TasksTab = ({ console.log('[Socket] Skipping echo update for locally updated task:', updatedTask.id); return; } - console.log('[Socket] Not an echo, applying update for task:', updatedTask.id); // Skip updates while modal is open for the same task to prevent conflicts if (isModalOpen && editingTask?.id === updatedTask.id) { @@ -147,13 +143,10 @@ export const TasksTab = ({ const existingTask = prev.find(task => task.id === updatedTask.id); if (!existingTask) { - console.log('[Socket] Task not found locally, skipping update for:', updatedTask.id); - console.log('[Socket] Current task IDs:', prev.map(t => t.id)); + // Task not found locally, skip the update return prev; } - console.log('[Socket] Updating task from:', existingTask.status, 'to:', mappedTask.status); - const updated = prev.map(task => task.id === updatedTask.id ? { ...mappedTask } @@ -696,7 +689,6 @@ export const TasksTab = ({ return updated; }); - console.log('[TasksTab] Task created successfully with optimistic update'); } catch (error) { console.error('Failed to create task:', error); From 4fa0e65dd3c50af194c8446183dabf9e5c600b86 Mon Sep 17 00:00:00 2001 From: sean-eskerium Date: Thu, 21 Aug 2025 03:10:43 -0400 Subject: [PATCH 12/12] - Fix the optimistic updates on the docs tab. --- .../src/components/project-tasks/DocsTab.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/archon-ui-main/src/components/project-tasks/DocsTab.tsx b/archon-ui-main/src/components/project-tasks/DocsTab.tsx index 1b30e7cd..fd605c3a 100644 --- a/archon-ui-main/src/components/project-tasks/DocsTab.tsx +++ b/archon-ui-main/src/components/project-tasks/DocsTab.tsx @@ -25,7 +25,7 @@ interface ProjectDoc { created_at: string; updated_at: string; // Content field stores markdown or structured data - content?: any; + content: any; document_type?: string; } @@ -574,9 +574,9 @@ export const DocsTab = ({ const projectDocuments: ProjectDoc[] = project.docs.map((doc: any) => ({ id: doc.id, title: doc.title || 'Untitled Document', - created_at: doc.created_at, - updated_at: doc.updated_at, - content: doc.content, + created_at: doc.created_at || new Date().toISOString(), + updated_at: doc.updated_at || new Date().toISOString(), + content: doc.content || {}, document_type: doc.document_type || 'document' })); @@ -629,19 +629,36 @@ export const DocsTab = ({ }); setSelectedDocument(tempDocument); setShowTemplateModal(false); + setIsSaving(false); // Allow UI to show the temp document try { setIsSaving(true); // Create document via backend API - const newDocument = await projectService.createDocument(project.id, { + const createdDoc = await projectService.createDocument(project.id, { title: template.name, content: template.content, document_type: template.document_type }); - // Force refresh to get the real document from server - await loadProjectDocuments(); + // Ensure the created document has all required fields + const newDocument: ProjectDoc = { + id: createdDoc.id, + title: createdDoc.title || template.name, + created_at: createdDoc.created_at || new Date().toISOString(), + updated_at: createdDoc.updated_at || new Date().toISOString(), + content: createdDoc.content || template.content, + document_type: createdDoc.document_type || template.document_type + }; + + // Replace temp document with real one - same pattern as tasks + setDocuments(prev => { + // Find and replace the temp document + const updated = prev.map(doc => + doc.id === tempDocument.id ? newDocument : doc + ); + return updated; + }); // Select the newly created document setSelectedDocument(newDocument);