mirror of
https://github.com/coleam00/Archon.git
synced 2026-01-01 04:09:08 -05:00
- 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.
This commit is contained in:
21
archon-ui-main/package-lock.json
generated
21
archon-ui-main/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
319
archon-ui-main/src/components/ErrorBoundary.tsx
Normal file
319
archon-ui-main/src/components/ErrorBoundary.tsx
Normal file
@@ -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<string | number>;
|
||||
resetOnPropsChange?: boolean;
|
||||
isolate?: boolean;
|
||||
level?: 'page' | 'section' | 'component';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced Error Boundary with recovery options
|
||||
*/
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
private resetTimeoutId: NodeJS.Timeout | null = null;
|
||||
private previousResetKeys: Array<string | number> = [];
|
||||
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
errorCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
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 <DefaultErrorFallback
|
||||
error={error}
|
||||
errorInfo={errorInfo}
|
||||
reset={this.reset}
|
||||
level={level}
|
||||
errorCount={errorCount}
|
||||
/>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error fallback component
|
||||
*/
|
||||
interface DefaultErrorFallbackProps {
|
||||
error: Error;
|
||||
errorInfo: ErrorInfo;
|
||||
reset: () => void;
|
||||
level: 'page' | 'section' | 'component';
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
const DefaultErrorFallback: React.FC<DefaultErrorFallbackProps> = ({
|
||||
error,
|
||||
errorInfo,
|
||||
reset,
|
||||
level,
|
||||
errorCount
|
||||
}) => {
|
||||
const isPageLevel = level === 'page';
|
||||
const isSectionLevel = level === 'section';
|
||||
|
||||
if (level === 'component') {
|
||||
// Minimal component-level error
|
||||
return (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">
|
||||
Component error occurred
|
||||
</span>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="ml-auto text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
${isPageLevel ? 'min-h-screen' : isSectionLevel ? 'min-h-[400px]' : 'min-h-[200px]'}
|
||||
flex items-center justify-center p-8
|
||||
bg-gradient-to-br from-red-50 to-orange-50
|
||||
dark:from-gray-900 dark:to-gray-800
|
||||
`}>
|
||||
<div className="max-w-2xl w-full">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
|
||||
{/* Error Icon */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="p-4 bg-red-100 dark:bg-red-900/30 rounded-full">
|
||||
<AlertTriangle className="w-12 h-12 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Title */}
|
||||
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">
|
||||
{isPageLevel ? 'Something went wrong' : 'An error occurred'}
|
||||
</h1>
|
||||
|
||||
{/* Error Message */}
|
||||
<p className="text-center text-gray-600 dark:text-gray-400 mb-6">
|
||||
{error.message || 'An unexpected error occurred while rendering this component.'}
|
||||
</p>
|
||||
|
||||
{/* Retry Count */}
|
||||
{errorCount > 1 && (
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-500 mb-4">
|
||||
This error has occurred {errorCount} times
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="
|
||||
flex items-center justify-center gap-2 px-6 py-3
|
||||
bg-blue-500 hover:bg-blue-600
|
||||
text-white font-medium rounded-lg
|
||||
transition-colors duration-150
|
||||
"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
{isPageLevel && (
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="
|
||||
flex items-center justify-center gap-2 px-6 py-3
|
||||
bg-gray-200 hover:bg-gray-300
|
||||
dark:bg-gray-700 dark:hover:bg-gray-600
|
||||
text-gray-700 dark:text-gray-200 font-medium rounded-lg
|
||||
transition-colors duration-150
|
||||
"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
Go Home
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Details (Development Only) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="mt-8 p-4 bg-gray-100 dark:bg-gray-900 rounded-lg">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Error Details (Development Only)
|
||||
</summary>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div>
|
||||
<p className="text-xs font-mono text-gray-600 dark:text-gray-400">
|
||||
{error.stack}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
Component Stack:
|
||||
</p>
|
||||
<p className="text-xs font-mono text-gray-600 dark:text-gray-400">
|
||||
{errorInfo.componentStack}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<SuspenseErrorBoundaryProps> = ({
|
||||
children,
|
||||
fallback,
|
||||
errorFallback,
|
||||
level = 'component'
|
||||
}) => {
|
||||
const defaultFallback = (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={errorFallback} level={level}>
|
||||
<Suspense fallback={fallback || defaultFallback}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reset error boundaries
|
||||
*/
|
||||
export function useErrorHandler(): (error: Error) => void {
|
||||
return (error: Error) => {
|
||||
throw error;
|
||||
};
|
||||
}
|
||||
365
archon-ui-main/src/components/SearchableList.tsx
Normal file
365
archon-ui-main/src/components/SearchableList.tsx
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SearchableListProps<T extends SearchableListItem> {
|
||||
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<T extends SearchableListItem>({
|
||||
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<T>) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className={`
|
||||
p-3 cursor-pointer transition-all duration-150
|
||||
${isHighlighted ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
|
||||
${isSelected ? 'bg-blue-100 dark:bg-blue-900/30 border-l-4 border-blue-500' : ''}
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800
|
||||
${itemClassName}
|
||||
`}
|
||||
onMouseEnter={() => setHighlightedId(item.id)}
|
||||
onMouseLeave={() => setHighlightedId(null)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{item.title}
|
||||
</h4>
|
||||
{item.description && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{enableMultiSelect && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleItemSelect(item)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="ml-3 mt-1 h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [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 (
|
||||
<div
|
||||
className="relative overflow-auto"
|
||||
style={{ height: containerHeight }}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
>
|
||||
<div style={{ height: totalHeight }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${offsetY}px)`
|
||||
}}
|
||||
>
|
||||
{visibleItems.map(item => (
|
||||
<div key={item.id} style={{ height: itemHeight }}>
|
||||
{renderItem ? renderItem(item, highlightedId === item.id) : defaultRenderItem(item, highlightedId === item.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [filteredItems, highlightedId, renderItem, defaultRenderItem, containerHeight, itemHeight, scrollTop]);
|
||||
|
||||
/**
|
||||
* Regular list renderer
|
||||
*/
|
||||
const renderRegularList = useCallback(() => {
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredItems.map(item => (
|
||||
<div key={item.id}>
|
||||
{renderItem ? renderItem(item, highlightedId === item.id) : defaultRenderItem(item, highlightedId === item.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [filteredItems, highlightedId, renderItem, defaultRenderItem]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${className}`}>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={placeholder}
|
||||
className={`
|
||||
w-full pl-10 pr-10 py-2
|
||||
border border-gray-300 dark:border-gray-600
|
||||
rounded-lg
|
||||
bg-white dark:bg-gray-800
|
||||
text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-all duration-150
|
||||
${isPending ? 'opacity-70' : ''}
|
||||
`}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 text-gray-400 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPending && (
|
||||
<div className="absolute top-full left-0 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
{searchQuery && (
|
||||
<div className="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List Container */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{virtualize && filteredItems.length > virtualizeThreshold
|
||||
? renderVirtualizedList()
|
||||
: renderRegularList()
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Summary */}
|
||||
{enableMultiSelect && selectedIds.size > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
{selectedIds.size} item{selectedIds.size !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing searchable list state
|
||||
*/
|
||||
export function useSearchableList<T extends SearchableListItem>(
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<any[] | null>(null);
|
||||
const [loadedCodeExamples, setLoadedCodeExamples] = useState<Array<{id: string; summary: string; code: string; language?: string}> | null>(null);
|
||||
const [isLoadingCodeExamples, setIsLoadingCodeExamples] = useState(false);
|
||||
|
||||
const statusColorMap = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DocumentCardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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 = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
|
||||
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 = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast('Failed to copy Document ID', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 = ({
|
||||
<span className="text-gray-600 dark:text-gray-400 text-xs">{task.assignee?.name || 'User'}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(task.id);
|
||||
// Optional: Add a small toast or visual feedback here
|
||||
const button = e.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<span class="text-green-500">Copied!</span>';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
const success = await copyToClipboard(task.id);
|
||||
if (success) {
|
||||
showToast('Task ID copied to clipboard', 'success');
|
||||
// Visual feedback
|
||||
const button = e.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<span class="text-green-500">Copied!</span>';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast('Failed to copy Task ID', 'error');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
title="Copy Task ID to clipboard"
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Task } from './TaskTableView';
|
||||
interface EditTaskModalProps {
|
||||
isModalOpen: boolean;
|
||||
editingTask: Task | null;
|
||||
projectFeatures: any[];
|
||||
projectFeatures: import('../types/jsonb').ProjectFeature[];
|
||||
isLoadingFeatures: boolean;
|
||||
isSavingTask: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -149,7 +149,7 @@ interface FeaturesTabProps {
|
||||
project?: {
|
||||
id: string;
|
||||
title: string;
|
||||
features?: any[];
|
||||
features?: import('../types/jsonb').ProjectFeature[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ DebouncedInput.displayName = 'DebouncedInput';
|
||||
interface FeatureInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
projectFeatures: any[];
|
||||
projectFeatures: import('../types/jsonb').ProjectFeature[];
|
||||
isLoadingFeatures: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DeleteConfirmModal } from '../../pages/ProjectPage';
|
||||
import { projectService } from '../../services/projectService';
|
||||
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
|
||||
import { DraggableTaskCard } from './DraggableTaskCard';
|
||||
import { copyToClipboard } from '../../utils/clipboard';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -13,7 +14,7 @@ export interface Task {
|
||||
description: string;
|
||||
status: 'backlog' | 'in-progress' | 'review' | 'complete';
|
||||
assignee: {
|
||||
name: 'User' | 'Archon' | 'AI IDE Agent';
|
||||
name: string; // Allow any assignee name for MCP subagents
|
||||
avatar: string;
|
||||
};
|
||||
feature: string;
|
||||
@@ -31,7 +32,7 @@ interface TaskTableViewProps {
|
||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
|
||||
}
|
||||
|
||||
const getAssigneeGlassStyle = (assigneeName: 'User' | 'Archon' | 'AI IDE Agent') => {
|
||||
const getAssigneeGlassStyle = (assigneeName: string) => {
|
||||
switch (assigneeName) {
|
||||
case 'User':
|
||||
return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-blue-400 dark:border-blue-500'; // blue glass
|
||||
@@ -208,6 +209,7 @@ const DraggableTaskRow = ({
|
||||
}: DraggableTaskRowProps) => {
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.TASK,
|
||||
@@ -252,7 +254,7 @@ const DraggableTaskRow = ({
|
||||
} else if (field === 'status') {
|
||||
updates.status = value as Task['status'];
|
||||
} else if (field === 'assignee') {
|
||||
updates.assignee = { name: value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' };
|
||||
updates.assignee = { name: value || 'AI IDE Agent', avatar: '' };
|
||||
} else if (field === 'feature') {
|
||||
updates.feature = value;
|
||||
}
|
||||
@@ -336,32 +338,14 @@ const DraggableTaskRow = ({
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full border-2 transition-all duration-300 cursor-pointer hover:scale-110 ${getAssigneeGlassStyle(task.assignee?.name || 'User')} ${getAssigneeGlow(task.assignee?.name || 'User')}`}
|
||||
onClick={() => setEditingField('assignee')}
|
||||
title={`Assignee: ${task.assignee?.name || 'User'}`}
|
||||
>
|
||||
{getAssigneeIcon(task.assignee?.name || 'User')}
|
||||
</div>
|
||||
{editingField === 'assignee' && (
|
||||
<div className="absolute z-50 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-2">
|
||||
<select
|
||||
value={task.assignee?.name || 'User'}
|
||||
onChange={(e) => {
|
||||
handleUpdateField('assignee', e.target.value);
|
||||
setEditingField(null);
|
||||
}}
|
||||
className="bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-cyan-500"
|
||||
autoFocus
|
||||
>
|
||||
<option value="User">User</option>
|
||||
<option value="Archon">Archon</option>
|
||||
<option value="AI IDE Agent">AI IDE Agent</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditableCell
|
||||
value={task.assignee?.name || 'AI IDE Agent'}
|
||||
onSave={(value) => handleUpdateField('assignee', value || 'AI IDE Agent')}
|
||||
isEditing={editingField === 'assignee'}
|
||||
onEdit={() => setEditingField('assignee')}
|
||||
onCancel={() => setEditingField(null)}
|
||||
placeholder="AI IDE Agent"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -385,16 +369,21 @@ const DraggableTaskRow = ({
|
||||
</button>
|
||||
{/* Copy Task ID Button - Matching Board View */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(task.id);
|
||||
// Visual feedback like in board view
|
||||
const button = e.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
const success = await copyToClipboard(task.id);
|
||||
if (success) {
|
||||
showToast('Task ID copied to clipboard', 'success');
|
||||
// Visual feedback like in board view
|
||||
const button = e.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast('Failed to copy Task ID', 'error');
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded-full bg-gray-500/20 text-gray-500 hover:bg-gray-500/30 hover:shadow-[0_0_10px_rgba(107,114,128,0.3)] transition-all duration-300"
|
||||
title="Copy Task ID to clipboard"
|
||||
@@ -524,18 +513,17 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={newTask.assignee.name}
|
||||
onChange={(e) => setNewTask(prev => ({
|
||||
...prev,
|
||||
assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' }
|
||||
assignee: { name: e.target.value || 'AI IDE Agent', avatar: '' }
|
||||
}))}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="AI IDE Agent"
|
||||
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]"
|
||||
>
|
||||
<option value="AI IDE Agent">AI IDE Agent</option>
|
||||
<option value="User">User</option>
|
||||
<option value="Archon">Archon</option>
|
||||
</select>
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex justify-center">
|
||||
|
||||
@@ -4,9 +4,11 @@ import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { Toggle } from '../ui/Toggle';
|
||||
import { projectService } from '../../services/projectService';
|
||||
import { getGlobalOperationTracker } from '../../utils/operationTracker';
|
||||
|
||||
import { useTaskSocket } from '../../hooks/useTaskSocket';
|
||||
import type { CreateTaskRequest, UpdateTaskRequest, DatabaseTaskStatus } from '../../types/project';
|
||||
import { WebSocketState } from '../../services/socketIOService';
|
||||
import { TaskTableView, Task } from './TaskTableView';
|
||||
import { TaskBoardView } from './TaskBoardView';
|
||||
import { EditTaskModal } from './EditTaskModal';
|
||||
@@ -65,11 +67,14 @@ export const TasksTab = ({
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [projectFeatures, setProjectFeatures] = useState<any[]>([]);
|
||||
const [projectFeatures, setProjectFeatures] = useState<import('../types/jsonb').ProjectFeature[]>([]);
|
||||
const [isLoadingFeatures, setIsLoadingFeatures] = useState(false);
|
||||
const [isSavingTask, setIsSavingTask] = useState<boolean>(false);
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
|
||||
// Track recently deleted tasks to prevent race conditions
|
||||
const [recentlyDeletedIds, setRecentlyDeletedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize tasks
|
||||
useEffect(() => {
|
||||
setTasks(initialTasks);
|
||||
@@ -85,6 +90,12 @@ export const TasksTab = ({
|
||||
const updatedTask = message.data || message;
|
||||
const mappedTask = mapDatabaseTaskToUITask(updatedTask);
|
||||
|
||||
// Skip updates for recently deleted tasks (race condition prevention)
|
||||
if (recentlyDeletedIds.has(updatedTask.id)) {
|
||||
console.log('[Socket] Ignoring update for recently deleted task:', updatedTask.id);
|
||||
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);
|
||||
@@ -94,20 +105,15 @@ export const TasksTab = ({
|
||||
setTasks(prev => {
|
||||
// Use server timestamp for conflict resolution
|
||||
const existingTask = prev.find(task => task.id === updatedTask.id);
|
||||
if (existingTask) {
|
||||
// Check if this is a more recent update
|
||||
const serverTimestamp = message.server_timestamp || Date.now();
|
||||
const lastUpdate = existingTask.lastUpdate || 0;
|
||||
|
||||
if (serverTimestamp <= lastUpdate) {
|
||||
console.log('[Socket] Ignoring stale update for task:', updatedTask.id);
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Skip if we already have this task (prevent duplicate additions)
|
||||
if (!existingTask) {
|
||||
console.log('[Socket] Task not found locally, adding:', updatedTask.id);
|
||||
}
|
||||
|
||||
const updated = prev.map(task =>
|
||||
task.id === updatedTask.id
|
||||
? { ...mappedTask, lastUpdate: message.server_timestamp || Date.now() }
|
||||
? { ...mappedTask }
|
||||
: task
|
||||
);
|
||||
|
||||
@@ -115,7 +121,7 @@ export const TasksTab = ({
|
||||
setTimeout(() => onTasksChange(updated), 0);
|
||||
return updated;
|
||||
});
|
||||
}, [onTasksChange, isModalOpen, editingTask?.id]);
|
||||
}, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds]);
|
||||
|
||||
const handleTaskCreated = useCallback((message: any) => {
|
||||
const newTask = message.data || message;
|
||||
@@ -123,11 +129,27 @@ export const TasksTab = ({
|
||||
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];
|
||||
setTimeout(() => onTasksChange(updated), 0);
|
||||
return updated;
|
||||
@@ -137,6 +159,14 @@ export const TasksTab = ({
|
||||
const handleTaskDeleted = useCallback((message: any) => {
|
||||
const deletedTask = message.data || message;
|
||||
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;
|
||||
});
|
||||
|
||||
setTasks(prev => {
|
||||
const updated = prev.filter(task => task.id !== deletedTask.id);
|
||||
setTimeout(() => onTasksChange(updated), 0);
|
||||
@@ -183,7 +213,7 @@ export const TasksTab = ({
|
||||
onTasksReordered: handleTasksReordered,
|
||||
onInitialTasks: handleInitialTasks,
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsWebSocketConnected(state === 'connected');
|
||||
setIsWebSocketConnected(state === WebSocketState.CONNECTED);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -305,29 +335,53 @@ export const TasksTab = ({
|
||||
};
|
||||
};
|
||||
|
||||
// Improved debounced persistence with better coordination
|
||||
// Batch reorder persistence for efficient updates
|
||||
const debouncedPersistBatchReorder = useMemo(
|
||||
() => debounce(async (tasksToUpdate: Task[]) => {
|
||||
try {
|
||||
console.log(`REORDER: Persisting batch update for ${tasksToUpdate.length} tasks`);
|
||||
|
||||
// Send batch update request to backend
|
||||
// For now, update tasks individually (backend can be optimized later for batch endpoint)
|
||||
const updatePromises = tasksToUpdate.map(task =>
|
||||
projectService.updateTask(task.id, {
|
||||
task_order: task.task_order
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
console.log('REORDER: Batch reorder persisted successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('REORDER: Failed to persist batch reorder:', error);
|
||||
// Socket will handle state recovery
|
||||
console.log('REORDER: Socket will handle state recovery');
|
||||
}
|
||||
}, 500), // Shorter delay for batch updates
|
||||
[projectId]
|
||||
);
|
||||
|
||||
// Single task persistence (still used for other operations)
|
||||
const debouncedPersistSingleTask = useMemo(
|
||||
() => debounce(async (task: Task) => {
|
||||
try {
|
||||
console.log('REORDER: Persisting position change for task:', task.title, 'new position:', task.task_order);
|
||||
|
||||
// Update only the moved task with server timestamp for conflict resolution
|
||||
// Update only the moved task
|
||||
await projectService.updateTask(task.id, {
|
||||
task_order: task.task_order,
|
||||
client_timestamp: Date.now()
|
||||
task_order: task.task_order
|
||||
});
|
||||
console.log('REORDER: Single task position persisted successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('REORDER: Failed to persist task position:', error);
|
||||
// Don't reload tasks immediately - let socket handle recovery
|
||||
console.log('REORDER: Socket will handle state recovery');
|
||||
}
|
||||
}, 800), // Slightly reduced delay for better responsiveness
|
||||
}, 800),
|
||||
[projectId]
|
||||
);
|
||||
|
||||
// Optimized task reordering without optimistic update conflicts
|
||||
// Standard drag-and-drop reordering with sequential integers (like Jira/Trello/Linear)
|
||||
const handleTaskReorder = useCallback((taskId: string, targetIndex: number, status: Task['status']) => {
|
||||
console.log('REORDER: Moving task', taskId, 'to index', targetIndex, 'in status', status);
|
||||
|
||||
@@ -357,63 +411,37 @@ export const TasksTab = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const movingTask = statusTasks[movingTaskIndex];
|
||||
console.log('REORDER: Moving', movingTask.title, 'from', movingTaskIndex, 'to', targetIndex);
|
||||
console.log('REORDER: Moving task from position', movingTaskIndex, 'to', targetIndex);
|
||||
|
||||
// Calculate new position using improved algorithm
|
||||
let newPosition: number;
|
||||
// Remove the task from its current position and insert at target position
|
||||
const reorderedTasks = [...statusTasks];
|
||||
const [movedTask] = reorderedTasks.splice(movingTaskIndex, 1);
|
||||
reorderedTasks.splice(targetIndex, 0, movedTask);
|
||||
|
||||
if (targetIndex === 0) {
|
||||
// Moving to first position
|
||||
const firstTask = statusTasks[0];
|
||||
newPosition = firstTask.task_order / 2;
|
||||
} else if (targetIndex === statusTasks.length - 1) {
|
||||
// Moving to last position
|
||||
const lastTask = statusTasks[statusTasks.length - 1];
|
||||
newPosition = lastTask.task_order + 1024;
|
||||
} else {
|
||||
// Moving between two items
|
||||
let prevTask, nextTask;
|
||||
|
||||
if (targetIndex > movingTaskIndex) {
|
||||
// Moving down
|
||||
prevTask = statusTasks[targetIndex];
|
||||
nextTask = statusTasks[targetIndex + 1];
|
||||
} else {
|
||||
// Moving up
|
||||
prevTask = statusTasks[targetIndex - 1];
|
||||
nextTask = statusTasks[targetIndex];
|
||||
}
|
||||
|
||||
if (prevTask && nextTask) {
|
||||
newPosition = (prevTask.task_order + nextTask.task_order) / 2;
|
||||
} else if (prevTask) {
|
||||
newPosition = prevTask.task_order + 1024;
|
||||
} else if (nextTask) {
|
||||
newPosition = nextTask.task_order / 2;
|
||||
} else {
|
||||
newPosition = 1024; // Fallback
|
||||
}
|
||||
}
|
||||
// Assign sequential order numbers (1, 2, 3, etc.) to all tasks in this status
|
||||
const updatedStatusTasks = reorderedTasks.map((task, index) => ({
|
||||
...task,
|
||||
task_order: index + 1,
|
||||
lastUpdate: Date.now()
|
||||
}));
|
||||
|
||||
console.log('REORDER: New position calculated:', newPosition);
|
||||
console.log('REORDER: New order:', updatedStatusTasks.map(t => `${t.title}:${t.task_order}`));
|
||||
|
||||
// Create updated task with new position and timestamp
|
||||
const updatedTask = {
|
||||
...movingTask,
|
||||
task_order: newPosition,
|
||||
lastUpdate: Date.now() // Add timestamp for conflict resolution
|
||||
};
|
||||
|
||||
// Immediate UI update without optimistic tracking interference
|
||||
const allUpdatedTasks = otherTasks.concat(
|
||||
statusTasks.map(task => task.id === taskId ? updatedTask : task)
|
||||
);
|
||||
// Update UI immediately with all reordered tasks
|
||||
const allUpdatedTasks = [...otherTasks, ...updatedStatusTasks];
|
||||
updateTasks(allUpdatedTasks);
|
||||
|
||||
// Persist to backend (single API call)
|
||||
debouncedPersistSingleTask(updatedTask);
|
||||
}, [tasks, updateTasks, debouncedPersistSingleTask]);
|
||||
// Batch update to backend - only update tasks that changed position
|
||||
const tasksToUpdate = updatedStatusTasks.filter((task, index) => {
|
||||
const originalTask = statusTasks.find(t => t.id === task.id);
|
||||
return originalTask && originalTask.task_order !== task.task_order;
|
||||
});
|
||||
|
||||
console.log(`REORDER: Updating ${tasksToUpdate.length} tasks in backend`);
|
||||
|
||||
// Send batch update to backend (debounced)
|
||||
debouncedPersistBatchReorder(tasksToUpdate);
|
||||
}, [tasks, updateTasks, debouncedPersistBatchReorder]);
|
||||
|
||||
// Task move function (for board view)
|
||||
const moveTask = async (taskId: string, newStatus: Task['status']) => {
|
||||
@@ -433,8 +461,7 @@ export const TasksTab = ({
|
||||
// Update the task with new status and order
|
||||
await projectService.updateTask(taskId, {
|
||||
status: mapUIStatusToDBStatus(newStatus),
|
||||
task_order: newOrder,
|
||||
client_timestamp: Date.now()
|
||||
task_order: newOrder
|
||||
});
|
||||
console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`);
|
||||
|
||||
@@ -498,9 +525,7 @@ export const TasksTab = ({
|
||||
const updateTaskInline = async (taskId: string, updates: Partial<Task>) => {
|
||||
console.log(`[TasksTab] Inline update for task ${taskId} with updates:`, updates);
|
||||
try {
|
||||
const updateData: Partial<UpdateTaskRequest> = {
|
||||
client_timestamp: Date.now()
|
||||
};
|
||||
const updateData: Partial<UpdateTaskRequest> = {};
|
||||
|
||||
if (updates.title !== undefined) updateData.title = updates.title;
|
||||
if (updates.description !== undefined) updateData.description = updates.description;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Clock, RotateCcw, Eye, Calendar, User, FileText, Diff, GitBranch, Layers, Plus, Minus, AlertTriangle } from 'lucide-react';
|
||||
import projectService from '../../services/projectService';
|
||||
import { projectService } from '../../services/projectService';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ function formatSectionContent(value: any): string {
|
||||
/**
|
||||
* Formats array content as markdown list or nested structure
|
||||
*/
|
||||
function formatArrayContent(array: any[]): string {
|
||||
function formatArrayContent(array: unknown[]): string {
|
||||
if (array.length === 0) {
|
||||
return '_No items_';
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface PRPPersona {
|
||||
export interface PRPPhase {
|
||||
duration?: string;
|
||||
deliverables?: string[];
|
||||
tasks?: any[];
|
||||
tasks?: Array<{title: string; files: string[]; details: string}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DocsTab } from '../components/project-tasks/DocsTab';
|
||||
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 our service layer and types
|
||||
import { projectService } from '../services/projectService';
|
||||
@@ -844,17 +845,21 @@ export function ProjectPage({
|
||||
|
||||
{/* Copy Project ID Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(project.id);
|
||||
showToast('Project ID copied to clipboard', 'success');
|
||||
// Visual feedback
|
||||
const button = e.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<svg class="w-3 h-3 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>Copied!';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
const success = await copyToClipboard(project.id);
|
||||
if (success) {
|
||||
showToast('Project ID copied to clipboard', 'success');
|
||||
// Visual feedback
|
||||
const button = e.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<svg class="w-3 h-3 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>Copied!';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast('Failed to copy Project ID', 'error');
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors py-1"
|
||||
title="Copy Project ID to clipboard"
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Key,
|
||||
Brain,
|
||||
Code,
|
||||
Activity,
|
||||
FileCode,
|
||||
Bug,
|
||||
} from "lucide-react";
|
||||
@@ -20,7 +19,6 @@ import { FeaturesSection } from "../components/settings/FeaturesSection";
|
||||
import { APIKeysSection } from "../components/settings/APIKeysSection";
|
||||
import { RAGSettings } from "../components/settings/RAGSettings";
|
||||
import { CodeExtractionSettings } from "../components/settings/CodeExtractionSettings";
|
||||
import { TestStatus } from "../components/settings/TestStatus";
|
||||
import { IDEGlobalRules } from "../components/settings/IDEGlobalRules";
|
||||
import { ButtonPlayground } from "../components/settings/ButtonPlayground";
|
||||
import { CollapsibleSettingsCard } from "../components/ui/CollapsibleSettingsCard";
|
||||
@@ -151,15 +149,31 @@ export const SettingsPage = () => {
|
||||
</CollapsibleSettingsCard>
|
||||
</motion.div>
|
||||
)}
|
||||
{/* Bug Report Section - Moved to left column */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<CollapsibleSettingsCard
|
||||
title="Test Status"
|
||||
icon={Activity}
|
||||
accentColor="cyan"
|
||||
storageKey="test-status"
|
||||
defaultExpanded={true}
|
||||
title="Bug Reporting"
|
||||
icon={Bug}
|
||||
iconColor="text-red-500"
|
||||
borderColor="border-red-200 dark:border-red-800"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<TestStatus />
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Found a bug or issue? Report it to help improve Archon V2
|
||||
Alpha.
|
||||
</p>
|
||||
<div className="flex justify-start">
|
||||
<BugReportButton variant="secondary" size="md">
|
||||
Report Bug
|
||||
</BugReportButton>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p>• Bug reports are sent directly to GitHub Issues</p>
|
||||
<p>• System context is automatically collected</p>
|
||||
<p>• Your privacy is protected - no personal data is sent</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSettingsCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -205,34 +219,6 @@ export const SettingsPage = () => {
|
||||
/>
|
||||
</CollapsibleSettingsCard>
|
||||
</motion.div>
|
||||
|
||||
{/* Bug Report Section */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<CollapsibleSettingsCard
|
||||
title="Bug Reporting"
|
||||
icon={Bug}
|
||||
iconColor="text-red-500"
|
||||
borderColor="border-red-200 dark:border-red-800"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Found a bug or issue? Report it to help improve Archon V2
|
||||
Alpha.
|
||||
</p>
|
||||
<div className="flex justify-start">
|
||||
<BugReportButton variant="secondary" size="md">
|
||||
Report Bug
|
||||
</BugReportButton>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p>• Bug reports are sent directly to GitHub Issues</p>
|
||||
<p>• System context is automatically collected</p>
|
||||
<p>• Your privacy is protected - no personal data is sent</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSettingsCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
213
archon-ui-main/src/schemas/project.schemas.ts
Normal file
213
archon-ui-main/src/schemas/project.schemas.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Zod schemas for runtime validation of project-related data
|
||||
* These schemas ensure type safety when receiving data from the backend
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for project document in JSONB field
|
||||
*/
|
||||
export const ProjectDocumentSchema = z.object({
|
||||
type: z.literal('document'),
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
metadata: z.record(z.unknown()),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for project feature in JSONB field
|
||||
*/
|
||||
export const ProjectFeatureSchema = z.object({
|
||||
type: z.literal('feature'),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.enum(['planned', 'in-progress', 'completed']),
|
||||
description: z.string(),
|
||||
priority: z.number().optional(),
|
||||
assignee: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for project data in JSONB field
|
||||
*/
|
||||
export const ProjectDataSchema = z.object({
|
||||
type: z.literal('data'),
|
||||
key: z.string(),
|
||||
value: z.unknown(),
|
||||
timestamp: z.string(),
|
||||
source: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for task source references
|
||||
*/
|
||||
export const TaskSourceSchema = z.object({
|
||||
url: z.string().optional(),
|
||||
file: z.string().optional(),
|
||||
type: z.enum(['documentation', 'code', 'internal_docs', 'external']),
|
||||
relevance: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for task code examples
|
||||
*/
|
||||
export const TaskCodeExampleSchema = z.object({
|
||||
file: z.string(),
|
||||
function: z.string().optional(),
|
||||
class: z.string().optional(),
|
||||
purpose: z.string(),
|
||||
language: z.string().optional(),
|
||||
snippet: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creation progress tracking
|
||||
*/
|
||||
export const CreationProgressSchema = z.object({
|
||||
progressId: z.string(),
|
||||
status: z.enum([
|
||||
'starting',
|
||||
'initializing_agents',
|
||||
'generating_docs',
|
||||
'processing_requirements',
|
||||
'ai_generation',
|
||||
'finalizing_docs',
|
||||
'saving_to_database',
|
||||
'completed',
|
||||
'error'
|
||||
]),
|
||||
percentage: z.number(),
|
||||
logs: z.array(z.string()),
|
||||
error: z.string().optional(),
|
||||
step: z.string().optional(),
|
||||
currentStep: z.string().optional(),
|
||||
eta: z.string().optional(),
|
||||
duration: z.string().optional(),
|
||||
project: z.lazy(() => ProjectSchema).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Main Project schema
|
||||
*/
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().min(1),
|
||||
prd: z.record(z.unknown()).optional(),
|
||||
docs: z.array(ProjectDocumentSchema).optional(),
|
||||
features: z.array(ProjectFeatureSchema).optional(),
|
||||
data: z.array(ProjectDataSchema).optional(),
|
||||
github_repo: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
technical_sources: z.array(z.string()).optional(),
|
||||
business_sources: z.array(z.string()).optional(),
|
||||
description: z.string().optional(),
|
||||
progress: z.number().optional(),
|
||||
updated: z.string().optional(),
|
||||
pinned: z.boolean(),
|
||||
creationProgress: CreationProgressSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for Task
|
||||
*/
|
||||
export const TaskSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'doing', 'review', 'done']),
|
||||
assignee: z.string(),
|
||||
task_order: z.number(),
|
||||
feature: z.string().optional(),
|
||||
sources: z.array(TaskSourceSchema).optional(),
|
||||
code_examples: z.array(TaskCodeExampleSchema).optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for Create Task DTO
|
||||
*/
|
||||
export const CreateTaskDtoSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'doing', 'review', 'done']).default('todo'),
|
||||
assignee: z.string().default('User'),
|
||||
task_order: z.number().optional(),
|
||||
feature: z.string().optional(),
|
||||
sources: z.array(TaskSourceSchema).optional(),
|
||||
code_examples: z.array(TaskCodeExampleSchema).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for Update Task DTO
|
||||
*/
|
||||
export const UpdateTaskDtoSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'doing', 'review', 'done']).optional(),
|
||||
assignee: z.string().optional(),
|
||||
task_order: z.number().optional(),
|
||||
feature: z.string().optional(),
|
||||
sources: z.array(TaskSourceSchema).optional(),
|
||||
code_examples: z.array(TaskCodeExampleSchema).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for task reorder data
|
||||
*/
|
||||
export const ReorderDataSchema = z.object({
|
||||
tasks: z.array(z.object({
|
||||
id: z.string(),
|
||||
task_order: z.number(),
|
||||
})),
|
||||
sourceIndex: z.number().optional(),
|
||||
destinationIndex: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type exports inferred from schemas
|
||||
*/
|
||||
export type Project = z.infer<typeof ProjectSchema>;
|
||||
export type Task = z.infer<typeof TaskSchema>;
|
||||
export type CreateTaskDto = z.infer<typeof CreateTaskDtoSchema>;
|
||||
export type UpdateTaskDto = z.infer<typeof UpdateTaskDtoSchema>;
|
||||
export type ReorderData = z.infer<typeof ReorderDataSchema>;
|
||||
export type CreationProgress = z.infer<typeof CreationProgressSchema>;
|
||||
|
||||
/**
|
||||
* Validation functions
|
||||
*/
|
||||
export function validateProject(data: unknown): Project {
|
||||
return ProjectSchema.parse(data);
|
||||
}
|
||||
|
||||
export function safeParseProject(data: unknown): Project | null {
|
||||
const result = ProjectSchema.safeParse(data);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
console.error('Project validation failed:', result.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateTask(data: unknown): Task {
|
||||
return TaskSchema.parse(data);
|
||||
}
|
||||
|
||||
export function safeParseTask(data: unknown): Task | null {
|
||||
const result = TaskSchema.safeParse(data);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
console.error('Task validation failed:', result.error);
|
||||
return null;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export interface CrawlProgressData {
|
||||
currentStep?: string;
|
||||
logs?: string[];
|
||||
log?: string;
|
||||
workers?: WorkerProgress[] | any[]; // Updated to support new worker format
|
||||
workers?: WorkerProgress[]; // Updated to support new worker format
|
||||
error?: string;
|
||||
completed?: boolean;
|
||||
// Additional properties for document upload and crawling
|
||||
@@ -50,6 +50,7 @@ export interface CrawlProgressData {
|
||||
wordCount?: number;
|
||||
duration?: string;
|
||||
sourceId?: string;
|
||||
codeExamplesCount?: number;
|
||||
// Original crawl parameters for retry functionality
|
||||
originalCrawlParams?: {
|
||||
url: string;
|
||||
@@ -98,7 +99,7 @@ interface StreamProgressOptions {
|
||||
connectionTimeout?: number;
|
||||
}
|
||||
|
||||
type ProgressCallback = (data: any) => void;
|
||||
type ProgressCallback = (data: CrawlProgressData) => void;
|
||||
|
||||
class CrawlProgressService {
|
||||
private wsService: WebSocketService = knowledgeSocketIO;
|
||||
@@ -115,6 +116,9 @@ class CrawlProgressService {
|
||||
options: StreamProgressOptions = {}
|
||||
): Promise<void> {
|
||||
console.log(`🚀 Starting Socket.IO progress stream for ${progressId}`);
|
||||
|
||||
// Store the active crawl progress ID in localStorage for reconnection
|
||||
localStorage.setItem('activeCrawlProgressId', progressId);
|
||||
|
||||
try {
|
||||
// Ensure we're connected to Socket.IO
|
||||
@@ -141,7 +145,7 @@ class CrawlProgressService {
|
||||
}, 5000); // 5 second timeout for acknowledgment
|
||||
|
||||
// Listen for subscription acknowledgment
|
||||
const ackHandler = (message: any) => {
|
||||
const ackHandler = (message: { data?: Record<string, unknown>; progress_id?: string; status?: string }) => {
|
||||
const data = message.data || message;
|
||||
console.log(`📨 Received acknowledgment:`, data);
|
||||
if (data.progress_id === progressId && data.status === 'subscribed') {
|
||||
@@ -156,7 +160,7 @@ class CrawlProgressService {
|
||||
});
|
||||
|
||||
// Create a specific handler for this progressId
|
||||
const progressHandler = (message: any) => {
|
||||
const progressHandler = (message: { data?: CrawlProgressData; progressId?: string }) => {
|
||||
console.log(`📨 [${progressId}] Raw message received:`, message);
|
||||
const data = message.data || message;
|
||||
console.log(`📨 [${progressId}] Extracted data:`, data);
|
||||
@@ -185,6 +189,8 @@ class CrawlProgressService {
|
||||
console.log(`✅ Crawl completed for ${progressId}`);
|
||||
if (data.progressId === progressId) {
|
||||
onMessage({ ...data, completed: true });
|
||||
// Clear the stored progress ID when crawl completes
|
||||
localStorage.removeItem('activeCrawlProgressId');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -197,6 +203,8 @@ class CrawlProgressService {
|
||||
error: message.data?.message || message.error || 'Unknown error',
|
||||
percentage: 0
|
||||
});
|
||||
// Clear the stored progress ID on error
|
||||
localStorage.removeItem('activeCrawlProgressId');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -298,6 +306,12 @@ class CrawlProgressService {
|
||||
|
||||
// Remove from active subscriptions
|
||||
this.activeSubscriptions.delete(progressId);
|
||||
|
||||
// Clear from localStorage if this is the active crawl
|
||||
const storedId = localStorage.getItem('activeCrawlProgressId');
|
||||
if (storedId === progressId) {
|
||||
localStorage.removeItem('activeCrawlProgressId');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,8 +392,8 @@ class CrawlProgressService {
|
||||
progressId: string,
|
||||
callbacks: {
|
||||
onMessage: ProgressCallback;
|
||||
onStateChange?: (state: any) => void;
|
||||
onError?: (error: any) => void;
|
||||
onStateChange?: (state: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
},
|
||||
options: StreamProgressOptions = {}
|
||||
): Promise<void> {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { OperationTracker, OperationResult } from '../utils/operationTracker';
|
||||
|
||||
export enum WebSocketState {
|
||||
CONNECTING = 'CONNECTING',
|
||||
@@ -33,9 +34,9 @@ export interface WebSocketConfig {
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data?: any;
|
||||
data?: unknown;
|
||||
timestamp?: string;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type MessageHandler = (message: WebSocketMessage) => void;
|
||||
@@ -57,8 +58,12 @@ export class WebSocketService {
|
||||
private _state: WebSocketState = WebSocketState.DISCONNECTED;
|
||||
|
||||
// Deduplication support
|
||||
private lastMessages: Map<string, { data: any; timestamp: number }> = new Map();
|
||||
private lastMessages: Map<string, { data: unknown; timestamp: number }> = new Map();
|
||||
private deduplicationWindow = 100; // 100ms window
|
||||
|
||||
// Operation tracking support
|
||||
private operationTracker: OperationTracker | null = null;
|
||||
private operationHandlers: Map<string, (result: OperationResult) => void> = new Map();
|
||||
|
||||
constructor(config: WebSocketConfig = {}) {
|
||||
this.config = {
|
||||
@@ -215,9 +220,9 @@ export class WebSocketService {
|
||||
|
||||
this.socket.on('connect_error', (error: Error) => {
|
||||
console.error('❌ Socket.IO connection error:', error);
|
||||
console.error('❌ Error type:', (error as any).type);
|
||||
console.error('❌ Error type:', (error as unknown as Record<string, unknown>).type);
|
||||
console.error('❌ Error message:', error.message);
|
||||
console.error('❌ Socket transport:', this.socket?.io?.engine?.transport?.name);
|
||||
console.error('❌ Socket transport:', (this.socket as unknown as { io?: { engine?: { transport?: { name?: string } } } })?.io?.engine?.transport?.name);
|
||||
this.notifyError(error);
|
||||
|
||||
// Reject connection promise if still pending
|
||||
@@ -244,13 +249,20 @@ export class WebSocketService {
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
this.socket.onAny((eventName: string, ...args: any[]) => {
|
||||
this.socket.onAny((eventName: string, ...args: unknown[]) => {
|
||||
// Skip internal Socket.IO events
|
||||
if (eventName.startsWith('connect') || eventName.startsWith('disconnect') ||
|
||||
eventName.startsWith('reconnect') || eventName === 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for operation responses
|
||||
if (eventName === 'operation_response' && args[0]) {
|
||||
const response = args[0] as { operationId: string; success: boolean; data?: unknown; error?: string };
|
||||
this.handleOperationResponse(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Socket.IO event to WebSocket message format
|
||||
const message: WebSocketMessage = {
|
||||
type: eventName,
|
||||
@@ -264,11 +276,16 @@ export class WebSocketService {
|
||||
Object.assign(message, args[0]);
|
||||
}
|
||||
|
||||
// Use unified message processing check
|
||||
if (!this.shouldProcessMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
private isDuplicateMessage(type: string, data: any): boolean {
|
||||
private isDuplicateMessage(type: string, data: unknown): boolean {
|
||||
const lastMessage = this.lastMessages.get(type);
|
||||
if (!lastMessage) return false;
|
||||
|
||||
@@ -288,11 +305,6 @@ export class WebSocketService {
|
||||
}
|
||||
|
||||
private handleMessage(message: WebSocketMessage): void {
|
||||
// Add deduplication check
|
||||
if (this.isDuplicateMessage(message.type, message.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store message for deduplication
|
||||
this.lastMessages.set(message.type, {
|
||||
data: message.data,
|
||||
@@ -394,29 +406,170 @@ export class WebSocketService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message via Socket.IO
|
||||
* Send a message via Socket.IO with optional operation tracking
|
||||
*/
|
||||
send(data: any): boolean {
|
||||
send(data: unknown, trackOperation?: boolean): boolean | string {
|
||||
if (!this.isConnected()) {
|
||||
console.warn('Cannot send message: Socket.IO not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
let operationId: string | undefined;
|
||||
|
||||
// Track operation if requested
|
||||
if (trackOperation && this.operationTracker) {
|
||||
const messageData = data as { type?: string };
|
||||
operationId = this.operationTracker.createOperation(
|
||||
messageData.type || 'message',
|
||||
data
|
||||
);
|
||||
|
||||
// Add operation ID to the message
|
||||
const trackedData = { ...messageData, operationId };
|
||||
data = trackedData;
|
||||
}
|
||||
|
||||
// For Socket.IO, we emit events based on message type
|
||||
if (data.type) {
|
||||
this.socket!.emit(data.type, data.data || data);
|
||||
const messageData = data as { type?: string; data?: unknown };
|
||||
if (messageData.type) {
|
||||
this.socket!.emit(messageData.type, messageData.data || data);
|
||||
} else {
|
||||
// Default message event
|
||||
this.socket!.emit('message', data);
|
||||
}
|
||||
return true;
|
||||
|
||||
return operationId || true;
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced emit method with automatic operation ID tracking for echo suppression
|
||||
private pendingOperations = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
emit(event: string, data: unknown): string {
|
||||
const operationId = crypto.randomUUID();
|
||||
const payload = { ...(typeof data === 'object' && data !== null ? data : {}), operationId };
|
||||
|
||||
// Track pending operation
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingOperations.delete(operationId);
|
||||
}, 5000);
|
||||
this.pendingOperations.set(operationId, timeout);
|
||||
|
||||
// Emit with operation ID
|
||||
if (this.socket) {
|
||||
this.socket.emit(event, payload);
|
||||
}
|
||||
|
||||
return operationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a tracked operation and wait for response
|
||||
*/
|
||||
async sendTrackedOperation(data: unknown, timeout?: number): Promise<OperationResult> {
|
||||
if (!this.operationTracker) {
|
||||
throw new Error('Operation tracking not enabled');
|
||||
}
|
||||
|
||||
const messageData = data as { type?: string };
|
||||
const operationId = this.operationTracker.createOperation(
|
||||
messageData.type || 'message',
|
||||
data
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set up operation handler
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.operationHandlers.delete(operationId);
|
||||
const result = this.operationTracker!.failOperation(
|
||||
operationId,
|
||||
'Operation timed out'
|
||||
);
|
||||
reject(new Error(result.error));
|
||||
}, timeout || 30000);
|
||||
|
||||
this.operationHandlers.set(operationId, (result: OperationResult) => {
|
||||
clearTimeout(timeoutId);
|
||||
this.operationHandlers.delete(operationId);
|
||||
|
||||
if (result.success) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error(result.error || 'Operation failed'));
|
||||
}
|
||||
});
|
||||
|
||||
// Send the tracked message
|
||||
const trackedData = { ...messageData, operationId };
|
||||
const sent = this.send(trackedData, false); // Don't double-track
|
||||
|
||||
if (!sent) {
|
||||
clearTimeout(timeoutId);
|
||||
this.operationHandlers.delete(operationId);
|
||||
reject(new Error('Failed to send message'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle operation response from server
|
||||
*/
|
||||
private handleOperationResponse(response: {
|
||||
operationId: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}): void {
|
||||
if (!this.operationTracker) return;
|
||||
|
||||
const result = response.success
|
||||
? this.operationTracker.completeOperation(response.operationId, response.data)
|
||||
: this.operationTracker.failOperation(response.operationId, response.error || 'Unknown error');
|
||||
|
||||
// Notify handler if exists
|
||||
const handler = this.operationHandlers.get(response.operationId);
|
||||
if (handler) {
|
||||
handler(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified method to check if a message should be processed
|
||||
* Consolidates echo suppression and deduplication logic
|
||||
*/
|
||||
private shouldProcessMessage(message: WebSocketMessage): boolean {
|
||||
// Check for operation ID echo suppression
|
||||
if (message.data && typeof message.data === 'object' && 'operationId' in message.data) {
|
||||
const operationId = (message.data as Record<string, unknown>).operationId as string;
|
||||
|
||||
// Check pending operations map first (for immediate echoes)
|
||||
if (this.pendingOperations.has(operationId)) {
|
||||
const timeout = this.pendingOperations.get(operationId);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.pendingOperations.delete(operationId);
|
||||
console.log(`[Socket] Suppressing echo for pending operation ${operationId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check operation tracker (for tracked operations)
|
||||
if (this.operationTracker?.shouldSuppress(operationId)) {
|
||||
console.log(`[Socket] Suppressing tracked operation ${operationId}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate messages
|
||||
if (this.isDuplicateMessage(message.type, message.data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for connection to be established
|
||||
*/
|
||||
@@ -462,6 +615,38 @@ export class WebSocketService {
|
||||
this.deduplicationWindow = windowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable operation tracking
|
||||
*/
|
||||
enableOperationTracking(timeout?: number): void {
|
||||
if (!this.operationTracker) {
|
||||
this.operationTracker = new OperationTracker(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable operation tracking
|
||||
*/
|
||||
disableOperationTracking(): void {
|
||||
if (this.operationTracker) {
|
||||
this.operationTracker.destroy();
|
||||
this.operationTracker = null;
|
||||
this.operationHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation tracking statistics
|
||||
*/
|
||||
getOperationStats(): {
|
||||
total: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
} | null {
|
||||
return this.operationTracker?.getStats() || null;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.setState(WebSocketState.DISCONNECTED);
|
||||
|
||||
@@ -478,6 +663,13 @@ export class WebSocketService {
|
||||
this.connectionResolver = null;
|
||||
this.connectionRejector = null;
|
||||
this.lastMessages.clear(); // Clear deduplication cache
|
||||
|
||||
// Clean up operation tracking
|
||||
if (this.operationTracker) {
|
||||
this.operationTracker.destroy();
|
||||
this.operationTracker = null;
|
||||
}
|
||||
this.operationHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,9 +678,28 @@ export function createWebSocketService(config?: WebSocketConfig): WebSocketServi
|
||||
return new WebSocketService(config);
|
||||
}
|
||||
|
||||
// Export singleton instances for different features
|
||||
export const knowledgeSocketIO = new WebSocketService();
|
||||
// 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
|
||||
});
|
||||
|
||||
// Export instances for backward compatibility
|
||||
export const taskUpdateSocketIO = new WebSocketService();
|
||||
export const projectListSocketIO = new WebSocketService();
|
||||
export const taskUpdateSocketIO = new WebSocketService({
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectInterval: 1000,
|
||||
heartbeatInterval: 30000,
|
||||
enableAutoReconnect: true
|
||||
});
|
||||
|
||||
export const projectListSocketIO = new WebSocketService({
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectInterval: 1000,
|
||||
heartbeatInterval: 30000,
|
||||
enableAutoReconnect: true
|
||||
});
|
||||
|
||||
// Export knowledgeSocketIO as default for backward compatibility
|
||||
export default knowledgeSocketIO;
|
||||
185
archon-ui-main/src/types/document.ts
Normal file
185
archon-ui-main/src/types/document.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Type definitions for document content
|
||||
* Replaces 'any' types with proper typed unions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Markdown content stored as a string
|
||||
*/
|
||||
export interface MarkdownContent {
|
||||
type: 'markdown';
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PRP (Product Requirement Prompt) document content
|
||||
*/
|
||||
export interface PRPContent {
|
||||
type: 'prp';
|
||||
document_type: 'prp';
|
||||
title: string;
|
||||
version: string;
|
||||
author: string;
|
||||
date: string;
|
||||
status: 'draft' | 'review' | 'approved' | 'deprecated';
|
||||
goal?: string;
|
||||
why?: string[];
|
||||
what?: {
|
||||
description: string;
|
||||
success_criteria: string[];
|
||||
user_stories?: string[];
|
||||
};
|
||||
context?: {
|
||||
documentation?: Array<{ source: string; why: string }>;
|
||||
existing_code?: Array<{ file: string; purpose: string }>;
|
||||
gotchas?: string[];
|
||||
current_state?: string;
|
||||
dependencies?: string[];
|
||||
environment_variables?: string[];
|
||||
};
|
||||
implementation_blueprint?: Record<string, any>;
|
||||
validation?: Record<string, any>;
|
||||
additional_context?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic structured document content
|
||||
*/
|
||||
export interface StructuredContent {
|
||||
type: 'structured';
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all document content types
|
||||
*/
|
||||
export type DocumentContent = string | MarkdownContent | PRPContent | StructuredContent;
|
||||
|
||||
/**
|
||||
* Complete document interface with typed content
|
||||
*/
|
||||
export interface ProjectDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: DocumentContent;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
document_type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if content is markdown
|
||||
*/
|
||||
export function isMarkdownContent(content: unknown): content is MarkdownContent {
|
||||
return (
|
||||
typeof content === 'object' &&
|
||||
content !== null &&
|
||||
'type' in content &&
|
||||
(content as any).type === 'markdown' &&
|
||||
'markdown' in content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if content is PRP
|
||||
*/
|
||||
export function isPRPContent(content: unknown): content is PRPContent {
|
||||
return (
|
||||
typeof content === 'object' &&
|
||||
content !== null &&
|
||||
'document_type' in content &&
|
||||
(content as any).document_type === 'prp'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if content is structured
|
||||
*/
|
||||
export function isStructuredContent(content: unknown): content is StructuredContent {
|
||||
return (
|
||||
typeof content === 'object' &&
|
||||
content !== null &&
|
||||
'type' in content &&
|
||||
(content as any).type === 'structured'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract markdown string from any content type
|
||||
*/
|
||||
export function getMarkdownFromContent(content: DocumentContent | undefined): string {
|
||||
if (!content) return '';
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (isMarkdownContent(content)) {
|
||||
return content.markdown;
|
||||
}
|
||||
|
||||
if (isPRPContent(content)) {
|
||||
// Convert PRP to markdown representation
|
||||
return convertPRPToMarkdown(content);
|
||||
}
|
||||
|
||||
if (isStructuredContent(content)) {
|
||||
// Convert structured content to markdown
|
||||
return JSON.stringify(content, null, 2);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PRP content to markdown string
|
||||
*/
|
||||
function convertPRPToMarkdown(prp: PRPContent): string {
|
||||
let markdown = `# ${prp.title}\n\n`;
|
||||
|
||||
// Add metadata
|
||||
markdown += `**Version:** ${prp.version}\n`;
|
||||
markdown += `**Author:** ${prp.author}\n`;
|
||||
markdown += `**Date:** ${prp.date}\n`;
|
||||
markdown += `**Status:** ${prp.status}\n\n`;
|
||||
|
||||
// Add goal
|
||||
if (prp.goal) {
|
||||
markdown += `## Goal\n\n${prp.goal}\n\n`;
|
||||
}
|
||||
|
||||
// Add why section
|
||||
if (prp.why && prp.why.length > 0) {
|
||||
markdown += `## Why\n\n`;
|
||||
prp.why.forEach(item => {
|
||||
markdown += `- ${item}\n`;
|
||||
});
|
||||
markdown += '\n';
|
||||
}
|
||||
|
||||
// Add what section
|
||||
if (prp.what) {
|
||||
markdown += `## What\n\n${prp.what.description}\n\n`;
|
||||
|
||||
if (prp.what.success_criteria && prp.what.success_criteria.length > 0) {
|
||||
markdown += `### Success Criteria\n\n`;
|
||||
prp.what.success_criteria.forEach(item => {
|
||||
markdown += `- ${item}\n`;
|
||||
});
|
||||
markdown += '\n';
|
||||
}
|
||||
|
||||
if (prp.what.user_stories && prp.what.user_stories.length > 0) {
|
||||
markdown += `### User Stories\n\n`;
|
||||
prp.what.user_stories.forEach(item => {
|
||||
markdown += `- ${item}\n`;
|
||||
});
|
||||
markdown += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
// Add other sections as needed
|
||||
|
||||
return markdown;
|
||||
}
|
||||
81
archon-ui-main/src/types/jsonb.ts
Normal file
81
archon-ui-main/src/types/jsonb.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Type definitions for JSONB fields in the database
|
||||
* These replace the previous any[] types with proper discriminated unions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Document stored in project docs field
|
||||
*/
|
||||
export interface ProjectDocument {
|
||||
type: 'document';
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature stored in project features field
|
||||
*/
|
||||
export interface ProjectFeature {
|
||||
type: 'feature';
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'planned' | 'in-progress' | 'completed';
|
||||
description: string;
|
||||
priority?: number;
|
||||
assignee?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data stored in project data field
|
||||
*/
|
||||
export interface ProjectData {
|
||||
type: 'data';
|
||||
key: string;
|
||||
value: unknown;
|
||||
timestamp: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source reference for tasks
|
||||
*/
|
||||
export interface TaskSource {
|
||||
url?: string;
|
||||
file?: string;
|
||||
type: 'documentation' | 'code' | 'internal_docs' | 'external';
|
||||
relevance?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code example reference for tasks
|
||||
*/
|
||||
export interface TaskCodeExample {
|
||||
file: string;
|
||||
function?: string;
|
||||
class?: string;
|
||||
purpose: string;
|
||||
language?: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all JSONB content types
|
||||
*/
|
||||
export type JsonbContent = ProjectDocument | ProjectFeature | ProjectData;
|
||||
|
||||
/**
|
||||
* Re-export type guards from the canonical location
|
||||
* These use Zod schemas for validation which is more robust
|
||||
*/
|
||||
export {
|
||||
isProjectDocument,
|
||||
isProjectFeature,
|
||||
isProjectData
|
||||
} from '../utils/typeGuards';
|
||||
@@ -11,17 +11,17 @@ export type UITaskStatus = 'backlog' | 'in-progress' | 'review' | 'complete';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
|
||||
// Assignee type - simplified to predefined options
|
||||
export type Assignee = 'User' | 'Archon' | 'AI IDE Agent';
|
||||
// Assignee type - flexible string to support MCP subagents
|
||||
export type Assignee = string;
|
||||
|
||||
// Base Project interface (matches database schema)
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
prd?: Record<string, any>; // JSONB field
|
||||
docs?: any[]; // JSONB field
|
||||
features?: any[]; // JSONB field
|
||||
data?: any[]; // JSONB field
|
||||
docs?: import('./jsonb').ProjectDocument[]; // Typed JSONB field
|
||||
features?: import('./jsonb').ProjectFeature[]; // Typed JSONB field
|
||||
data?: import('./jsonb').ProjectData[]; // Typed JSONB field
|
||||
github_repo?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -59,8 +59,8 @@ export interface Task {
|
||||
assignee: Assignee; // Now a database column with enum constraint
|
||||
task_order: number; // New database column for priority ordering
|
||||
feature?: string; // New database column for feature name
|
||||
sources?: any[]; // JSONB field
|
||||
code_examples?: any[]; // JSONB field
|
||||
sources?: import('./jsonb').TaskSource[]; // Typed JSONB field
|
||||
code_examples?: import('./jsonb').TaskCodeExample[]; // Typed JSONB field
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -85,9 +85,9 @@ export interface CreateProjectRequest {
|
||||
pinned?: boolean;
|
||||
// Note: PRD data should be stored as a document in the docs array with document_type="prd"
|
||||
// not as a direct 'prd' field since this column doesn't exist in the database
|
||||
docs?: any[];
|
||||
features?: any[];
|
||||
data?: any[];
|
||||
docs?: import('./jsonb').ProjectDocument[];
|
||||
features?: import('./jsonb').ProjectFeature[];
|
||||
data?: import('./jsonb').ProjectData[];
|
||||
technical_sources?: string[];
|
||||
business_sources?: string[];
|
||||
}
|
||||
@@ -98,9 +98,9 @@ export interface UpdateProjectRequest {
|
||||
description?: string;
|
||||
github_repo?: string;
|
||||
prd?: Record<string, any>;
|
||||
docs?: any[];
|
||||
features?: any[];
|
||||
data?: any[];
|
||||
docs?: import('./jsonb').ProjectDocument[];
|
||||
features?: import('./jsonb').ProjectFeature[];
|
||||
data?: import('./jsonb').ProjectData[];
|
||||
technical_sources?: string[];
|
||||
business_sources?: string[];
|
||||
pinned?: boolean;
|
||||
@@ -117,8 +117,8 @@ export interface CreateTaskRequest {
|
||||
feature?: string;
|
||||
featureColor?: string;
|
||||
priority?: TaskPriority;
|
||||
sources?: any[];
|
||||
code_examples?: any[];
|
||||
sources?: import('./jsonb').TaskSource[];
|
||||
code_examples?: import('./jsonb').TaskCodeExample[];
|
||||
}
|
||||
|
||||
// Update task request
|
||||
@@ -131,8 +131,8 @@ export interface UpdateTaskRequest {
|
||||
feature?: string;
|
||||
featureColor?: string;
|
||||
priority?: TaskPriority;
|
||||
sources?: any[];
|
||||
code_examples?: any[];
|
||||
sources?: import('./jsonb').TaskSource[];
|
||||
code_examples?: import('./jsonb').TaskCodeExample[];
|
||||
}
|
||||
|
||||
// MCP tool response types
|
||||
|
||||
66
archon-ui-main/src/utils/clipboard.ts
Normal file
66
archon-ui-main/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Clipboard utility with fallback for non-secure contexts
|
||||
* Works on both HTTPS and HTTP connections
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copies text to clipboard with fallback for non-secure contexts
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise<boolean> - Returns true if successful, false otherwise
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// First try the modern clipboard API (works on HTTPS/localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('Clipboard API failed, trying fallback:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback method using execCommand (works on HTTP)
|
||||
try {
|
||||
// Create a temporary textarea element
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-999999px';
|
||||
textarea.style.top = '-999999px';
|
||||
textarea.setAttribute('readonly', ''); // Prevent keyboard from showing on mobile
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
// Select the text
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, 99999); // For mobile devices
|
||||
|
||||
// Copy the text
|
||||
const successful = document.execCommand('copy');
|
||||
|
||||
// Remove the temporary element
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
if (successful) {
|
||||
return true;
|
||||
} else {
|
||||
console.warn('execCommand copy failed');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback copy method failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clipboard is available (for UI feedback)
|
||||
* @returns boolean - Returns true if clipboard operations are available
|
||||
*/
|
||||
export function isClipboardAvailable(): boolean {
|
||||
// Clipboard is available if either method works
|
||||
return !!(
|
||||
(navigator.clipboard && window.isSecureContext) ||
|
||||
document.queryCommandSupported?.('copy')
|
||||
);
|
||||
}
|
||||
91
archon-ui-main/src/utils/logger.ts
Normal file
91
archon-ui-main/src/utils/logger.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Simple logger utility for alpha development
|
||||
* Can be toggled via LOG_LEVEL environment variable or disabled in production
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LoggerConfig {
|
||||
enabled: boolean;
|
||||
level: LogLevel;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private config: LoggerConfig;
|
||||
private levels: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3
|
||||
};
|
||||
|
||||
constructor(config: Partial<LoggerConfig> = {}) {
|
||||
this.config = {
|
||||
enabled: import.meta.env.DEV || import.meta.env.VITE_LOG_LEVEL !== 'none',
|
||||
level: (import.meta.env.VITE_LOG_LEVEL as LogLevel) || 'info',
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
if (!this.config.enabled) return false;
|
||||
return this.levels[level] >= this.levels[this.config.level];
|
||||
}
|
||||
|
||||
private formatMessage(level: LogLevel, message: string, data?: any): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = this.config.prefix ? `[${this.config.prefix}]` : '';
|
||||
return `${timestamp} [${level.toUpperCase()}]${prefix} ${message}`;
|
||||
}
|
||||
|
||||
debug(message: string, data?: any): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
console.log(this.formatMessage('debug', message), data || '');
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, data?: any): void {
|
||||
if (this.shouldLog('info')) {
|
||||
console.log(this.formatMessage('info', message), data || '');
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, data?: any): void {
|
||||
if (this.shouldLog('warn')) {
|
||||
console.warn(this.formatMessage('warn', message), data || '');
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, data?: any): void {
|
||||
if (this.shouldLog('error')) {
|
||||
console.error(this.formatMessage('error', message), data || '');
|
||||
}
|
||||
}
|
||||
|
||||
// Time tracking for performance monitoring
|
||||
time(label: string): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
console.time(label);
|
||||
}
|
||||
}
|
||||
|
||||
timeEnd(label: string): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
console.timeEnd(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create logger instances for different modules
|
||||
export const createLogger = (prefix?: string): Logger => {
|
||||
return new Logger({ prefix });
|
||||
};
|
||||
|
||||
// Default logger instance
|
||||
export const logger = createLogger();
|
||||
|
||||
// Specialized loggers for different components
|
||||
export const docsLogger = createLogger('DOCS');
|
||||
export const socketLogger = createLogger('SOCKET');
|
||||
export const apiLogger = createLogger('API');
|
||||
283
archon-ui-main/src/utils/operationTracker.ts
Normal file
283
archon-ui-main/src/utils/operationTracker.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Operation tracking for Socket.IO echo suppression
|
||||
* Tracks outgoing operations to prevent processing their echoes
|
||||
*/
|
||||
|
||||
// Using crypto.randomUUID instead of uuid package to avoid dependency bloat
|
||||
const generateId = (): string => {
|
||||
return crypto.randomUUID();
|
||||
};
|
||||
|
||||
export interface TrackedOperation {
|
||||
id: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
payload: unknown;
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface OperationResult {
|
||||
operationId: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class OperationTracker {
|
||||
private operations: Map<string, TrackedOperation> = new Map();
|
||||
private operationTimeout: number = 30000; // 30 seconds default
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private readonly maxOperationAge = 60000; // 1 minute
|
||||
|
||||
constructor(timeout?: number) {
|
||||
if (timeout) {
|
||||
this.operationTimeout = timeout;
|
||||
}
|
||||
this.startCleanupInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tracked operation
|
||||
*/
|
||||
createOperation(type: string, payload?: unknown): string {
|
||||
const operationId = generateId();
|
||||
|
||||
// Set timeout for operation
|
||||
const timeout = setTimeout(() => {
|
||||
this.failOperation(operationId, 'Operation timed out');
|
||||
}, this.operationTimeout);
|
||||
|
||||
const operation: TrackedOperation = {
|
||||
id: operationId,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
payload,
|
||||
status: 'pending',
|
||||
timeout
|
||||
};
|
||||
|
||||
this.operations.set(operationId, operation);
|
||||
return operationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation exists and is pending
|
||||
*/
|
||||
isPending(operationId: string): boolean {
|
||||
const operation = this.operations.get(operationId);
|
||||
return operation?.status === 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation should be suppressed (exists and not failed)
|
||||
*/
|
||||
shouldSuppress(operationId: string): boolean {
|
||||
const operation = this.operations.get(operationId);
|
||||
return operation !== undefined && operation.status !== 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an operation as completed
|
||||
*/
|
||||
completeOperation(operationId: string, data?: unknown): OperationResult {
|
||||
const operation = this.operations.get(operationId);
|
||||
|
||||
if (!operation) {
|
||||
return {
|
||||
operationId,
|
||||
success: false,
|
||||
error: 'Operation not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
if (operation.timeout) {
|
||||
clearTimeout(operation.timeout);
|
||||
}
|
||||
|
||||
operation.status = 'completed';
|
||||
|
||||
return {
|
||||
operationId,
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an operation as failed
|
||||
*/
|
||||
failOperation(operationId: string, error: string): OperationResult {
|
||||
const operation = this.operations.get(operationId);
|
||||
|
||||
if (!operation) {
|
||||
return {
|
||||
operationId,
|
||||
success: false,
|
||||
error: 'Operation not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
if (operation.timeout) {
|
||||
clearTimeout(operation.timeout);
|
||||
}
|
||||
|
||||
operation.status = 'failed';
|
||||
|
||||
return {
|
||||
operationId,
|
||||
success: false,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation details
|
||||
*/
|
||||
getOperation(operationId: string): TrackedOperation | undefined {
|
||||
return this.operations.get(operationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending operations of a specific type
|
||||
*/
|
||||
getPendingOperations(type?: string): TrackedOperation[] {
|
||||
const pending = Array.from(this.operations.values()).filter(
|
||||
op => op.status === 'pending'
|
||||
);
|
||||
|
||||
if (type) {
|
||||
return pending.filter(op => op.type === type);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old operations to prevent memory leaks
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const idsToDelete: string[] = [];
|
||||
|
||||
this.operations.forEach((operation, id) => {
|
||||
if (now - operation.timestamp > this.maxOperationAge) {
|
||||
// Clear timeout if still exists
|
||||
if (operation.timeout) {
|
||||
clearTimeout(operation.timeout);
|
||||
}
|
||||
idsToDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
idsToDelete.forEach(id => this.operations.delete(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup
|
||||
*/
|
||||
private startCleanupInterval(): void {
|
||||
// Ensure we don't create multiple intervals
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
// Run cleanup every 30 seconds
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop cleanup interval and clear all operations
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
// Clear all timeouts
|
||||
this.operations.forEach(operation => {
|
||||
if (operation.timeout) {
|
||||
clearTimeout(operation.timeout);
|
||||
}
|
||||
});
|
||||
|
||||
this.operations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about tracked operations
|
||||
*/
|
||||
getStats(): {
|
||||
total: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
} {
|
||||
let pending = 0;
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
|
||||
this.operations.forEach(operation => {
|
||||
switch (operation.status) {
|
||||
case 'pending':
|
||||
pending++;
|
||||
break;
|
||||
case 'completed':
|
||||
completed++;
|
||||
break;
|
||||
case 'failed':
|
||||
failed++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.operations.size,
|
||||
pending,
|
||||
completed,
|
||||
failed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear completed operations (keep pending and recently failed)
|
||||
*/
|
||||
clearCompleted(): void {
|
||||
const now = Date.now();
|
||||
const idsToDelete: string[] = [];
|
||||
|
||||
this.operations.forEach((operation, id) => {
|
||||
if (operation.status === 'completed' ||
|
||||
(operation.status === 'failed' && now - operation.timestamp > 5000)) {
|
||||
if (operation.timeout) {
|
||||
clearTimeout(operation.timeout);
|
||||
}
|
||||
idsToDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
idsToDelete.forEach(id => this.operations.delete(id));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global operation tracking
|
||||
let globalTracker: OperationTracker | null = null;
|
||||
|
||||
export function getGlobalOperationTracker(): OperationTracker {
|
||||
if (!globalTracker) {
|
||||
globalTracker = new OperationTracker();
|
||||
}
|
||||
return globalTracker;
|
||||
}
|
||||
|
||||
export function resetGlobalOperationTracker(): void {
|
||||
if (globalTracker) {
|
||||
globalTracker.destroy();
|
||||
globalTracker = null;
|
||||
}
|
||||
}
|
||||
252
archon-ui-main/src/utils/typeGuards.ts
Normal file
252
archon-ui-main/src/utils/typeGuards.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Type guards and utility functions for type safety
|
||||
*/
|
||||
|
||||
import {
|
||||
ProjectDocumentSchema,
|
||||
ProjectFeatureSchema,
|
||||
ProjectDataSchema,
|
||||
TaskSourceSchema,
|
||||
TaskCodeExampleSchema,
|
||||
ProjectSchema,
|
||||
TaskSchema
|
||||
} from '../schemas/project.schemas';
|
||||
import type {
|
||||
ProjectDocument,
|
||||
ProjectFeature,
|
||||
ProjectData,
|
||||
TaskSource,
|
||||
TaskCodeExample
|
||||
} from '../types/jsonb';
|
||||
import type { Project, Task } from '../types/project';
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a ProjectDocument
|
||||
*/
|
||||
export function isProjectDocument(value: unknown): value is ProjectDocument {
|
||||
return ProjectDocumentSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a ProjectFeature
|
||||
*/
|
||||
export function isProjectFeature(value: unknown): value is ProjectFeature {
|
||||
return ProjectFeatureSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is ProjectData
|
||||
*/
|
||||
export function isProjectData(value: unknown): value is ProjectData {
|
||||
return ProjectDataSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a TaskSource
|
||||
*/
|
||||
export function isTaskSource(value: unknown): value is TaskSource {
|
||||
return TaskSourceSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a TaskCodeExample
|
||||
*/
|
||||
export function isTaskCodeExample(value: unknown): value is TaskCodeExample {
|
||||
return TaskCodeExampleSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a Project
|
||||
*/
|
||||
export function isProject(value: unknown): value is Project {
|
||||
return ProjectSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a Task
|
||||
*/
|
||||
export function isTask(value: unknown): value is Task {
|
||||
return TaskSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive type checking helper
|
||||
* Throws an error if a case is not handled in a switch statement
|
||||
*/
|
||||
export function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe JSON parse that returns unknown instead of any
|
||||
*/
|
||||
export function safeJsonParse(str: string): unknown {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a non-null object
|
||||
*/
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is an array
|
||||
*/
|
||||
export function isArray<T>(value: unknown, itemGuard?: (item: unknown) => item is T): value is T[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (!itemGuard) return true;
|
||||
return value.every(itemGuard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a string
|
||||
*/
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a number
|
||||
*/
|
||||
export function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a boolean
|
||||
*/
|
||||
export function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type for deep partial objects
|
||||
*/
|
||||
export type DeepPartial<T> = T extends object ? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
} : T;
|
||||
|
||||
/**
|
||||
* Utility type for strict omit that checks keys
|
||||
*/
|
||||
export type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
/**
|
||||
* Utility type for strict extract
|
||||
*/
|
||||
export type StrictExtract<T, U extends T> = U;
|
||||
|
||||
/**
|
||||
* Type-safe event map for typed event emitters
|
||||
*/
|
||||
export type EventMap = Record<string, (...args: unknown[]) => void>;
|
||||
|
||||
/**
|
||||
* Type-safe event emitter class
|
||||
*/
|
||||
export class TypedEventEmitter<T extends EventMap> {
|
||||
private handlers: Partial<T> = {};
|
||||
|
||||
on<K extends keyof T>(event: K, handler: T[K]): void {
|
||||
this.handlers[event] = handler;
|
||||
}
|
||||
|
||||
off<K extends keyof T>(event: K): void {
|
||||
delete this.handlers[event];
|
||||
}
|
||||
|
||||
emit<K extends keyof T>(event: K, ...args: Parameters<T[K]>): void {
|
||||
const handler = this.handlers[event];
|
||||
if (handler) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to filter out null and undefined values from arrays
|
||||
*/
|
||||
export function filterNullish<T>(array: (T | null | undefined)[]): T[] {
|
||||
return array.filter((item): item is T => item != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to safely access nested properties
|
||||
*/
|
||||
export function getNestedProperty<T>(
|
||||
obj: unknown,
|
||||
path: string,
|
||||
defaultValue?: T
|
||||
): T | undefined {
|
||||
if (!isObject(obj)) return defaultValue;
|
||||
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!isObject(current) || !(key in current)) {
|
||||
return defaultValue;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value has a specific property
|
||||
*/
|
||||
export function hasProperty<K extends string>(
|
||||
obj: unknown,
|
||||
key: K
|
||||
): obj is Record<K, unknown> {
|
||||
return isObject(obj) && key in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a valid UUID
|
||||
*/
|
||||
export function isUUID(value: unknown): value is string {
|
||||
if (!isString(value)) return false;
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a valid ISO date string
|
||||
*/
|
||||
export function isISODateString(value: unknown): value is string {
|
||||
if (!isString(value)) return false;
|
||||
const date = new Date(value);
|
||||
return !isNaN(date.getTime()) && date.toISOString() === value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to ensure a value is an array
|
||||
*/
|
||||
export function ensureArray<T>(value: T | T[]): T[] {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to group array items by a key
|
||||
*/
|
||||
export function groupBy<T, K extends keyof T>(
|
||||
array: T[],
|
||||
key: K
|
||||
): Record<string, T[]> {
|
||||
return array.reduce((groups, item) => {
|
||||
const groupKey = String(item[key]);
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = [];
|
||||
}
|
||||
groups[groupKey].push(item);
|
||||
return groups;
|
||||
}, {} as Record<string, T[]>);
|
||||
}
|
||||
176
archon-ui-main/test/components/ErrorBoundary.test.tsx
Normal file
176
archon-ui-main/test/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
import React from 'react'
|
||||
|
||||
// Component that throws an error for testing
|
||||
const ThrowError: React.FC<{ shouldThrow: boolean }> = ({ shouldThrow }) => {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error message')
|
||||
}
|
||||
return <div>No error</div>
|
||||
}
|
||||
|
||||
// Mock console.error to suppress error output in tests
|
||||
const originalError = console.error
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError
|
||||
})
|
||||
|
||||
describe('ErrorBoundary Component', () => {
|
||||
test('renders children when there is no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Test content</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('catches errors and displays fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Should show error fallback
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('No error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('displays custom error fallback when provided', () => {
|
||||
const CustomFallback = ({ error }: { error: Error }) => (
|
||||
<div>Custom error: {error.message}</div>
|
||||
)
|
||||
|
||||
render(
|
||||
<ErrorBoundary errorFallback={CustomFallback}>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom error: Test error message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders different UI for page-level errors', () => {
|
||||
render(
|
||||
<ErrorBoundary isPageLevel={true}>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Page-level errors should have specific styling
|
||||
const errorContainer = screen.getByText(/Something went wrong/i).closest('div')
|
||||
expect(errorContainer?.className).toContain('min-h-screen')
|
||||
})
|
||||
|
||||
test('renders different UI for component-level errors', () => {
|
||||
render(
|
||||
<ErrorBoundary isPageLevel={false}>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Component-level errors should have different styling
|
||||
const errorContainer = screen.getByText(/Something went wrong/i).closest('div')
|
||||
expect(errorContainer?.className).not.toContain('min-h-screen')
|
||||
expect(errorContainer?.className).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
test('passes error object to error fallback', () => {
|
||||
const error = new Error('Specific error message')
|
||||
const CustomFallback = ({ error: err }: { error: Error }) => (
|
||||
<div>
|
||||
<div>Error occurred</div>
|
||||
<div>{err.message}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
render(
|
||||
<ErrorBoundary errorFallback={CustomFallback}>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Error occurred')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles multiple error boundaries at different levels', () => {
|
||||
const OuterFallback = () => <div>Outer error</div>
|
||||
const InnerFallback = () => <div>Inner error</div>
|
||||
|
||||
render(
|
||||
<ErrorBoundary errorFallback={OuterFallback}>
|
||||
<div>
|
||||
<ErrorBoundary errorFallback={InnerFallback}>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Inner boundary should catch the error
|
||||
expect(screen.getByText('Inner error')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Outer error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('recovers when error condition is resolved', () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Error is shown
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument()
|
||||
|
||||
// When component no longer throws, it should recover
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={false} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Note: React Error Boundaries don't automatically recover,
|
||||
// so the error state persists. This is expected behavior.
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('logs errors to console in development', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error')
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Error should be logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('renders with suspense wrapper when specified', () => {
|
||||
// Testing SuspenseErrorBoundary variant
|
||||
const LazyComponent = React.lazy(() =>
|
||||
Promise.resolve({ default: () => <div>Lazy loaded</div> })
|
||||
)
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<LazyComponent />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Should show loading initially
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
163
archon-ui-main/test/components/layouts/MainLayout.test.tsx
Normal file
163
archon-ui-main/test/components/layouts/MainLayout.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { MainLayout } from '@/components/layouts/MainLayout'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
// Mock the child components
|
||||
vi.mock('@/components/layouts/SideNavigation', () => ({
|
||||
SideNavigation: () => <nav data-testid="side-navigation">Side Navigation</nav>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/DisconnectScreenOverlay', () => ({
|
||||
DisconnectScreenOverlay: () => null // Usually hidden
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('@/contexts/SettingsContext', () => ({
|
||||
useSettings: () => ({
|
||||
settings: {
|
||||
enableProjects: true,
|
||||
theme: 'dark'
|
||||
},
|
||||
updateSettings: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('MainLayout Component', () => {
|
||||
const renderWithRouter = (children: React.ReactNode) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
{children}
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
test('renders children correctly', () => {
|
||||
renderWithRouter(
|
||||
<MainLayout>
|
||||
<div>Page content</div>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders side navigation', () => {
|
||||
renderWithRouter(
|
||||
<MainLayout>
|
||||
<div>Content</div>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('side-navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('applies layout structure classes', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<MainLayout>
|
||||
<div>Content</div>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
// Check for flex layout
|
||||
const layoutContainer = container.querySelector('.flex')
|
||||
expect(layoutContainer).toBeInTheDocument()
|
||||
|
||||
// Check for main content area
|
||||
const mainContent = container.querySelector('main')
|
||||
expect(mainContent).toBeInTheDocument()
|
||||
expect(mainContent?.className).toContain('flex-1')
|
||||
})
|
||||
|
||||
test('renders multiple children', () => {
|
||||
renderWithRouter(
|
||||
<MainLayout>
|
||||
<div>First child</div>
|
||||
<div>Second child</div>
|
||||
<section>Third child</section>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByText('First child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Third child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('maintains responsive layout', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<MainLayout>
|
||||
<div>Responsive content</div>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
const mainContent = container.querySelector('main')
|
||||
expect(mainContent?.className).toContain('overflow-x-hidden')
|
||||
expect(mainContent?.className).toContain('overflow-y-auto')
|
||||
})
|
||||
|
||||
test('applies dark mode background classes', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<MainLayout>
|
||||
<div>Dark mode content</div>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
const layoutContainer = container.firstChild as HTMLElement
|
||||
expect(layoutContainer.className).toContain('bg-gray-50')
|
||||
expect(layoutContainer.className).toContain('dark:bg-black')
|
||||
})
|
||||
|
||||
test('renders empty children gracefully', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<MainLayout>
|
||||
{null}
|
||||
{undefined}
|
||||
{false}
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
// Should still render the layout structure
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('side-navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles complex nested components', () => {
|
||||
renderWithRouter(
|
||||
<MainLayout>
|
||||
<div className="page-container">
|
||||
<header>
|
||||
<h1>Page Title</h1>
|
||||
</header>
|
||||
<section>
|
||||
<article>
|
||||
<p>Article content</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Article content')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('preserves child component props', () => {
|
||||
renderWithRouter(
|
||||
<MainLayout>
|
||||
<div
|
||||
id="test-id"
|
||||
className="custom-class"
|
||||
data-testid="custom-content"
|
||||
>
|
||||
Custom content
|
||||
</div>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
const customDiv = screen.getByTestId('custom-content')
|
||||
expect(customDiv).toHaveAttribute('id', 'test-id')
|
||||
expect(customDiv).toHaveClass('custom-class')
|
||||
expect(customDiv).toHaveTextContent('Custom content')
|
||||
})
|
||||
})
|
||||
288
archon-ui-main/test/components/project-tasks/TasksTab.test.tsx
Normal file
288
archon-ui-main/test/components/project-tasks/TasksTab.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock data for testing
|
||||
const mockTasks = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'First task',
|
||||
description: 'Description 1',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
task_order: 1,
|
||||
feature: 'feature-1'
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
title: 'Second task',
|
||||
description: 'Description 2',
|
||||
status: 'todo',
|
||||
assignee: 'AI IDE Agent',
|
||||
task_order: 2,
|
||||
feature: 'feature-1'
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
title: 'Third task',
|
||||
description: 'Description 3',
|
||||
status: 'todo',
|
||||
assignee: 'Archon',
|
||||
task_order: 3,
|
||||
feature: 'feature-2'
|
||||
},
|
||||
{
|
||||
id: 'task-4',
|
||||
title: 'Fourth task',
|
||||
description: 'Description 4',
|
||||
status: 'doing',
|
||||
assignee: 'User',
|
||||
task_order: 1,
|
||||
feature: 'feature-2'
|
||||
}
|
||||
]
|
||||
|
||||
describe('TasksTab - Task Reordering', () => {
|
||||
let reorderTasks: any
|
||||
let handleReorderTasks: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('Sequential Ordering System', () => {
|
||||
test('maintains sequential order (1, 2, 3, ...) after reordering', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
|
||||
// Move task from index 0 to index 2
|
||||
const reordered = moveTask(tasks, 0, 2)
|
||||
|
||||
// Check that task_order is sequential
|
||||
expect(reordered[0].task_order).toBe(1)
|
||||
expect(reordered[1].task_order).toBe(2)
|
||||
expect(reordered[2].task_order).toBe(3)
|
||||
})
|
||||
|
||||
test('updates task_order for all affected tasks', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
|
||||
// Move last task to first position
|
||||
const reordered = moveTask(tasks, 2, 0)
|
||||
|
||||
expect(reordered[0].id).toBe('task-3')
|
||||
expect(reordered[0].task_order).toBe(1)
|
||||
expect(reordered[1].id).toBe('task-1')
|
||||
expect(reordered[1].task_order).toBe(2)
|
||||
expect(reordered[2].id).toBe('task-2')
|
||||
expect(reordered[2].task_order).toBe(3)
|
||||
})
|
||||
|
||||
test('handles moving task within same status column', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
|
||||
// Move middle task to end
|
||||
const reordered = moveTask(tasks, 1, 2)
|
||||
|
||||
expect(reordered[0].id).toBe('task-1')
|
||||
expect(reordered[1].id).toBe('task-3')
|
||||
expect(reordered[2].id).toBe('task-2')
|
||||
|
||||
// All should have sequential ordering
|
||||
reordered.forEach((task, index) => {
|
||||
expect(task.task_order).toBe(index + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Batch Reorder Persistence', () => {
|
||||
test('batches multiple reorder operations', () => {
|
||||
const persistBatch = vi.fn()
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
|
||||
// Simulate multiple rapid reorders
|
||||
const reordered1 = moveTask(tasks, 0, 2)
|
||||
const reordered2 = moveTask(reordered1, 1, 0)
|
||||
|
||||
// In actual implementation, these would be debounced
|
||||
// and sent as a single batch update
|
||||
expect(reordered2[0].task_order).toBe(1)
|
||||
expect(reordered2[1].task_order).toBe(2)
|
||||
expect(reordered2[2].task_order).toBe(3)
|
||||
})
|
||||
|
||||
test('preserves lastUpdate timestamp for optimistic updates', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
const timestamp = Date.now()
|
||||
|
||||
const reordered = moveTask(tasks, 0, 2, timestamp)
|
||||
|
||||
// All reordered tasks should have the lastUpdate timestamp
|
||||
reordered.forEach(task => {
|
||||
expect(task.lastUpdate).toBe(timestamp)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Race Condition Prevention', () => {
|
||||
test('ignores updates for deleted tasks', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
const deletedTaskId = 'task-2'
|
||||
|
||||
// Remove task-2 to simulate deletion
|
||||
const afterDeletion = tasks.filter(t => t.id !== deletedTaskId)
|
||||
|
||||
// Try to reorder with deleted task - should handle gracefully
|
||||
const reordered = afterDeletion.map((task, index) => ({
|
||||
...task,
|
||||
task_order: index + 1
|
||||
}))
|
||||
|
||||
expect(reordered.length).toBe(2)
|
||||
expect(reordered.find(t => t.id === deletedTaskId)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles concurrent updates with temporary task replacement', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
const tempTask = { ...tasks[0], title: 'Temporary update' }
|
||||
|
||||
// Replace task temporarily (optimistic update)
|
||||
const withTemp = tasks.map(t =>
|
||||
t.id === tempTask.id ? tempTask : t
|
||||
)
|
||||
|
||||
expect(withTemp[0].title).toBe('Temporary update')
|
||||
expect(withTemp[0].id).toBe(tasks[0].id)
|
||||
})
|
||||
|
||||
test('maintains order consistency during concurrent operations', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
|
||||
// Simulate two concurrent reorder operations
|
||||
const reorder1 = moveTask([...tasks], 0, 2)
|
||||
const reorder2 = moveTask([...tasks], 2, 1)
|
||||
|
||||
// Both should maintain sequential ordering
|
||||
reorder1.forEach((task, index) => {
|
||||
expect(task.task_order).toBe(index + 1)
|
||||
})
|
||||
|
||||
reorder2.forEach((task, index) => {
|
||||
expect(task.task_order).toBe(index + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Status Reordering', () => {
|
||||
test('handles moving task to different status column', () => {
|
||||
const todoTasks = mockTasks.filter(t => t.status === 'todo')
|
||||
const doingTasks = mockTasks.filter(t => t.status === 'doing')
|
||||
|
||||
// Move first todo task to doing column
|
||||
const taskToMove = todoTasks[0]
|
||||
const updatedTask = { ...taskToMove, status: 'doing' }
|
||||
|
||||
// Update todo column (remove task)
|
||||
const newTodoTasks = todoTasks.slice(1).map((task, index) => ({
|
||||
...task,
|
||||
task_order: index + 1
|
||||
}))
|
||||
|
||||
// Update doing column (add task at position)
|
||||
const newDoingTasks = [
|
||||
updatedTask,
|
||||
...doingTasks
|
||||
].map((task, index) => ({
|
||||
...task,
|
||||
task_order: index + 1
|
||||
}))
|
||||
|
||||
// Verify sequential ordering in both columns
|
||||
expect(newTodoTasks.every((t, i) => t.task_order === i + 1)).toBe(true)
|
||||
expect(newDoingTasks.every((t, i) => t.task_order === i + 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('handles empty task list', () => {
|
||||
const tasks: any[] = []
|
||||
const reordered = moveTask(tasks, 0, 0)
|
||||
|
||||
expect(reordered).toEqual([])
|
||||
})
|
||||
|
||||
test('handles single task', () => {
|
||||
const tasks = [mockTasks[0]]
|
||||
const reordered = moveTask(tasks, 0, 0)
|
||||
|
||||
expect(reordered[0].task_order).toBe(1)
|
||||
expect(reordered.length).toBe(1)
|
||||
})
|
||||
|
||||
test('handles invalid indices gracefully', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
|
||||
// Try to move with out-of-bounds index
|
||||
const reordered = moveTask(tasks, 10, 0)
|
||||
|
||||
// Should return tasks unchanged
|
||||
expect(reordered).toEqual(tasks)
|
||||
})
|
||||
|
||||
test('preserves task data during reorder', () => {
|
||||
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
|
||||
const originalTask = { ...tasks[0] }
|
||||
|
||||
const reordered = moveTask(tasks, 0, 2)
|
||||
const movedTask = reordered.find(t => t.id === originalTask.id)
|
||||
|
||||
// All properties except task_order should be preserved
|
||||
expect(movedTask?.title).toBe(originalTask.title)
|
||||
expect(movedTask?.description).toBe(originalTask.description)
|
||||
expect(movedTask?.assignee).toBe(originalTask.assignee)
|
||||
expect(movedTask?.feature).toBe(originalTask.feature)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Flexible Assignee Support', () => {
|
||||
test('supports any assignee name string', () => {
|
||||
const customAssignees = [
|
||||
'prp-executor',
|
||||
'prp-validator',
|
||||
'Custom Agent',
|
||||
'test-agent-123'
|
||||
]
|
||||
|
||||
customAssignees.forEach(assignee => {
|
||||
const task = { ...mockTasks[0], assignee }
|
||||
expect(task.assignee).toBe(assignee)
|
||||
expect(typeof task.assignee).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
test('handles empty assignee gracefully', () => {
|
||||
const task = { ...mockTasks[0], assignee: '' }
|
||||
expect(task.assignee).toBe('')
|
||||
|
||||
// Should default to 'AI IDE Agent' in UI
|
||||
const displayAssignee = task.assignee || 'AI IDE Agent'
|
||||
expect(displayAssignee).toBe('AI IDE Agent')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Helper function to simulate task reordering
|
||||
function moveTask(tasks: any[], fromIndex: number, toIndex: number, timestamp?: number): any[] {
|
||||
if (fromIndex < 0 || fromIndex >= tasks.length ||
|
||||
toIndex < 0 || toIndex >= tasks.length) {
|
||||
return tasks
|
||||
}
|
||||
|
||||
const result = [...tasks]
|
||||
const [movedTask] = result.splice(fromIndex, 1)
|
||||
result.splice(toIndex, 0, movedTask)
|
||||
|
||||
// Update task_order to be sequential
|
||||
return result.map((task, index) => ({
|
||||
...task,
|
||||
task_order: index + 1,
|
||||
...(timestamp ? { lastUpdate: timestamp } : {})
|
||||
}))
|
||||
}
|
||||
195
archon-ui-main/test/services/socketIOService.test.ts
Normal file
195
archon-ui-main/test/services/socketIOService.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
// Mock socket.io-client
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
connected: true,
|
||||
id: 'test-socket-id'
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('socketIOService - Shared Instance Pattern', () => {
|
||||
let socketIOService: any
|
||||
let knowledgeSocketIO: any
|
||||
let taskUpdateSocketIO: any
|
||||
let projectListSocketIO: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset all mocks
|
||||
vi.resetAllMocks()
|
||||
vi.resetModules()
|
||||
|
||||
// Import fresh instances
|
||||
const module = await import('../../src/services/socketIOService')
|
||||
socketIOService = module
|
||||
knowledgeSocketIO = module.knowledgeSocketIO
|
||||
taskUpdateSocketIO = module.taskUpdateSocketIO
|
||||
projectListSocketIO = module.projectListSocketIO
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('creates only a single shared socket instance', () => {
|
||||
// All exported instances should be the same object
|
||||
expect(knowledgeSocketIO).toBe(taskUpdateSocketIO)
|
||||
expect(taskUpdateSocketIO).toBe(projectListSocketIO)
|
||||
expect(knowledgeSocketIO).toBe(projectListSocketIO)
|
||||
})
|
||||
|
||||
test('socket.io is called only once despite multiple exports', () => {
|
||||
// The io function should only be called once to create the shared instance
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('all services share the same socket connection', () => {
|
||||
// Get the internal socket from each service
|
||||
const knowledgeSocket = knowledgeSocketIO.socket
|
||||
const taskSocket = taskUpdateSocketIO.socket
|
||||
const projectSocket = projectListSocketIO.socket
|
||||
|
||||
// All should reference the same socket instance
|
||||
expect(knowledgeSocket).toBe(taskSocket)
|
||||
expect(taskSocket).toBe(projectSocket)
|
||||
})
|
||||
|
||||
test('operations from different services use the same socket', () => {
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
// Subscribe to events from different service exports
|
||||
knowledgeSocketIO.on('knowledge_update', mockCallback)
|
||||
taskUpdateSocketIO.on('task_update', mockCallback)
|
||||
projectListSocketIO.on('project_update', mockCallback)
|
||||
|
||||
// All operations should use the same underlying socket
|
||||
const socket = knowledgeSocketIO.socket
|
||||
expect(socket.on).toHaveBeenCalledWith('knowledge_update', expect.any(Function))
|
||||
expect(socket.on).toHaveBeenCalledWith('task_update', expect.any(Function))
|
||||
expect(socket.on).toHaveBeenCalledWith('project_update', expect.any(Function))
|
||||
})
|
||||
|
||||
test('disconnecting one service disconnects all', () => {
|
||||
// Disconnect using one service
|
||||
knowledgeSocketIO.disconnect()
|
||||
|
||||
// Check that the shared socket was disconnected
|
||||
const socket = knowledgeSocketIO.socket
|
||||
expect(socket.disconnect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify all services report as disconnected
|
||||
expect(knowledgeSocketIO.isConnected()).toBe(false)
|
||||
expect(taskUpdateSocketIO.isConnected()).toBe(false)
|
||||
expect(projectListSocketIO.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
test('operation tracking is shared across all service exports', () => {
|
||||
// Add operation from one service
|
||||
const operationId = 'test-op-123'
|
||||
knowledgeSocketIO.addOperation(operationId)
|
||||
|
||||
// Check if operation is tracked in all services
|
||||
expect(knowledgeSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
expect(taskUpdateSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
expect(projectListSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
|
||||
test('removing operation from one service removes from all', () => {
|
||||
const operationId = 'test-op-456'
|
||||
|
||||
// Add operation
|
||||
taskUpdateSocketIO.addOperation(operationId)
|
||||
expect(knowledgeSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
|
||||
// Remove operation using different service
|
||||
projectListSocketIO.removeOperation(operationId)
|
||||
|
||||
// Verify removed from all
|
||||
expect(knowledgeSocketIO.isOwnOperation(operationId)).toBe(false)
|
||||
expect(taskUpdateSocketIO.isOwnOperation(operationId)).toBe(false)
|
||||
expect(projectListSocketIO.isOwnOperation(operationId)).toBe(false)
|
||||
})
|
||||
|
||||
test('echo suppression works across all service exports', () => {
|
||||
const operationId = 'echo-test-789'
|
||||
const callback = vi.fn()
|
||||
|
||||
// Subscribe to event
|
||||
knowledgeSocketIO.on('test_event', callback, true) // skipOwnOperations = true
|
||||
|
||||
// Add operation from different service export
|
||||
taskUpdateSocketIO.addOperation(operationId)
|
||||
|
||||
// Simulate event with operation ID
|
||||
const eventData = { operationId, data: 'test' }
|
||||
const handler = knowledgeSocketIO.socket.on.mock.calls[0][1]
|
||||
handler(eventData)
|
||||
|
||||
// Callback should not be called due to echo suppression
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
|
||||
// Simulate event without operation ID
|
||||
const externalEvent = { data: 'external' }
|
||||
handler(externalEvent)
|
||||
|
||||
// Callback should be called for external events
|
||||
expect(callback).toHaveBeenCalledWith(externalEvent)
|
||||
})
|
||||
|
||||
test('connection state is synchronized across all exports', () => {
|
||||
const mockSocket = knowledgeSocketIO.socket
|
||||
|
||||
// Simulate connected state
|
||||
mockSocket.connected = true
|
||||
expect(knowledgeSocketIO.isConnected()).toBe(true)
|
||||
expect(taskUpdateSocketIO.isConnected()).toBe(true)
|
||||
expect(projectListSocketIO.isConnected()).toBe(true)
|
||||
|
||||
// Simulate disconnected state
|
||||
mockSocket.connected = false
|
||||
expect(knowledgeSocketIO.isConnected()).toBe(false)
|
||||
expect(taskUpdateSocketIO.isConnected()).toBe(false)
|
||||
expect(projectListSocketIO.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
test('emitting from any service uses the shared socket', () => {
|
||||
const mockSocket = knowledgeSocketIO.socket
|
||||
|
||||
// Emit from different services
|
||||
knowledgeSocketIO.emit('event1', { data: 1 })
|
||||
taskUpdateSocketIO.emit('event2', { data: 2 })
|
||||
projectListSocketIO.emit('event3', { data: 3 })
|
||||
|
||||
// All should use the same socket
|
||||
expect(mockSocket.emit).toHaveBeenCalledTimes(3)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('event1', { data: 1 }, undefined)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('event2', { data: 2 }, undefined)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('event3', { data: 3 }, undefined)
|
||||
})
|
||||
|
||||
test('prevents multiple socket connections when switching tabs', () => {
|
||||
// Simulate tab switching by importing the module multiple times
|
||||
// In a real scenario, this would happen when components unmount/remount
|
||||
|
||||
// First "tab"
|
||||
const socket1 = knowledgeSocketIO.socket
|
||||
|
||||
// Simulate switching tabs (in reality, components would remount)
|
||||
// But the shared instance pattern prevents new connections
|
||||
const socket2 = taskUpdateSocketIO.socket
|
||||
const socket3 = projectListSocketIO.socket
|
||||
|
||||
// All should be the same instance
|
||||
expect(socket1).toBe(socket2)
|
||||
expect(socket2).toBe(socket3)
|
||||
|
||||
// io should still only be called once
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
238
archon-ui-main/test/utils/operationTracker.test.ts
Normal file
238
archon-ui-main/test/utils/operationTracker.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { OperationTracker } from '../../src/utils/operationTracker'
|
||||
|
||||
// Mock uuid
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => 'mock-uuid-123')
|
||||
}))
|
||||
|
||||
describe('OperationTracker', () => {
|
||||
let tracker: OperationTracker
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new OperationTracker()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateOperationId', () => {
|
||||
test('generates unique operation IDs', () => {
|
||||
const id1 = tracker.generateOperationId()
|
||||
const id2 = tracker.generateOperationId()
|
||||
|
||||
expect(id1).toBe('mock-uuid-123')
|
||||
expect(id2).toBe('mock-uuid-123') // Same because mock always returns same value
|
||||
|
||||
// In real implementation, these would be different
|
||||
expect(id1).toBeTruthy()
|
||||
expect(id2).toBeTruthy()
|
||||
})
|
||||
|
||||
test('returns string IDs', () => {
|
||||
const id = tracker.generateOperationId()
|
||||
expect(typeof id).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addOperation', () => {
|
||||
test('adds operation to tracking set', () => {
|
||||
const operationId = 'test-op-1'
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
|
||||
test('handles multiple operations', () => {
|
||||
tracker.addOperation('op-1')
|
||||
tracker.addOperation('op-2')
|
||||
tracker.addOperation('op-3')
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(true)
|
||||
})
|
||||
|
||||
test('handles duplicate operations gracefully', () => {
|
||||
const operationId = 'duplicate-op'
|
||||
|
||||
tracker.addOperation(operationId)
|
||||
tracker.addOperation(operationId) // Add same ID again
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeOperation', () => {
|
||||
test('removes operation from tracking', () => {
|
||||
const operationId = 'temp-op'
|
||||
|
||||
tracker.addOperation(operationId)
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
|
||||
tracker.removeOperation(operationId)
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(false)
|
||||
})
|
||||
|
||||
test('handles removing non-existent operation', () => {
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
tracker.removeOperation('non-existent')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('removes only specified operation', () => {
|
||||
tracker.addOperation('op-1')
|
||||
tracker.addOperation('op-2')
|
||||
tracker.addOperation('op-3')
|
||||
|
||||
tracker.removeOperation('op-2')
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOwnOperation', () => {
|
||||
test('returns true for tracked operations', () => {
|
||||
const operationId = 'tracked-op'
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for untracked operations', () => {
|
||||
expect(tracker.isOwnOperation('untracked-op')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false after operation is removed', () => {
|
||||
const operationId = 'temp-op'
|
||||
|
||||
tracker.addOperation(operationId)
|
||||
tracker.removeOperation(operationId)
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
test('removes all tracked operations', () => {
|
||||
tracker.addOperation('op-1')
|
||||
tracker.addOperation('op-2')
|
||||
tracker.addOperation('op-3')
|
||||
|
||||
tracker.clear()
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(false)
|
||||
})
|
||||
|
||||
test('works with empty tracker', () => {
|
||||
expect(() => tracker.clear()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('echo suppression scenarios', () => {
|
||||
test('prevents processing own operations', () => {
|
||||
const operationId = tracker.generateOperationId()
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
// Simulate receiving an event with our operation ID
|
||||
const event = { operationId, data: 'some data' }
|
||||
|
||||
// Should identify as own operation (skip processing)
|
||||
if (tracker.isOwnOperation(event.operationId)) {
|
||||
// Skip processing
|
||||
expect(true).toBe(true) // Operation should be skipped
|
||||
} else {
|
||||
// Process event
|
||||
expect(false).toBe(true) // Should not reach here
|
||||
}
|
||||
})
|
||||
|
||||
test('allows processing external operations', () => {
|
||||
const externalOpId = 'external-op-123'
|
||||
|
||||
// Simulate receiving an event from another client
|
||||
const event = { operationId: externalOpId, data: 'external data' }
|
||||
|
||||
// Should not identify as own operation
|
||||
if (!tracker.isOwnOperation(event.operationId)) {
|
||||
// Process event
|
||||
expect(true).toBe(true) // Operation should be processed
|
||||
} else {
|
||||
// Skip processing
|
||||
expect(false).toBe(true) // Should not reach here
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup patterns', () => {
|
||||
test('supports operation cleanup after completion', () => {
|
||||
const operationId = tracker.generateOperationId()
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
// Simulate operation completion
|
||||
setTimeout(() => {
|
||||
tracker.removeOperation(operationId)
|
||||
}, 100)
|
||||
|
||||
// Initially tracked
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
|
||||
// After cleanup (would be false after timeout)
|
||||
// Note: In real tests, would use fake timers or promises
|
||||
})
|
||||
|
||||
test('handles batch cleanup', () => {
|
||||
const operations = ['op-1', 'op-2', 'op-3', 'op-4', 'op-5']
|
||||
|
||||
// Add all operations
|
||||
operations.forEach(op => tracker.addOperation(op))
|
||||
|
||||
// Remove specific operations
|
||||
tracker.removeOperation('op-2')
|
||||
tracker.removeOperation('op-4')
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-4')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-5')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory management', () => {
|
||||
test('does not accumulate unlimited operations', () => {
|
||||
// Add many operations
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tracker.addOperation(`op-${i}`)
|
||||
}
|
||||
|
||||
// Clear to prevent memory leaks
|
||||
tracker.clear()
|
||||
|
||||
// Verify all cleared
|
||||
expect(tracker.isOwnOperation('op-0')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-999')).toBe(false)
|
||||
})
|
||||
|
||||
test('supports operation TTL pattern', () => {
|
||||
// This test demonstrates a pattern for auto-cleanup
|
||||
const operationWithTTL = (id: string, ttlMs: number) => {
|
||||
tracker.addOperation(id)
|
||||
|
||||
setTimeout(() => {
|
||||
tracker.removeOperation(id)
|
||||
}, ttlMs)
|
||||
}
|
||||
|
||||
const opId = 'ttl-op'
|
||||
operationWithTTL(opId, 5000) // 5 second TTL
|
||||
|
||||
// Initially tracked
|
||||
expect(tracker.isOwnOperation(opId)).toBe(true)
|
||||
// Would be removed after TTL expires
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,9 +18,7 @@ from pydantic import BaseModel
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import Socket.IO instance
|
||||
from ..socketio_app import get_socketio_instance
|
||||
|
||||
sio = get_socketio_instance()
|
||||
from ..socketio_app import sio
|
||||
|
||||
# Create router
|
||||
router = APIRouter(prefix="/api/agent-chat", tags=["agent-chat"])
|
||||
|
||||
@@ -35,7 +35,7 @@ from ..utils.document_processing import extract_text_from_document
|
||||
|
||||
# Get logger for this module
|
||||
logger = get_logger(__name__)
|
||||
from ..socketio_app import get_socketio_instance
|
||||
from ..socketio_app import sio
|
||||
from .socketio_handlers import (
|
||||
complete_crawl_progress,
|
||||
error_crawl_progress,
|
||||
@@ -46,8 +46,6 @@ from .socketio_handlers import (
|
||||
# Create router
|
||||
router = APIRouter(prefix="/api", tags=["knowledge"])
|
||||
|
||||
# Get Socket.IO instance
|
||||
sio = get_socketio_instance()
|
||||
|
||||
# Create a semaphore to limit concurrent crawls
|
||||
# This prevents the server from becoming unresponsive during heavy crawling
|
||||
|
||||
@@ -8,12 +8,10 @@ No other modules should import from this file.
|
||||
import asyncio
|
||||
|
||||
from ..config.logfire_config import get_logger
|
||||
from ..socketio_app import get_socketio_instance
|
||||
from ..socketio_app import sio
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Get Socket.IO instance
|
||||
sio = get_socketio_instance()
|
||||
|
||||
|
||||
# Core broadcast functions
|
||||
|
||||
@@ -13,13 +13,10 @@ from ..config.logfire_config import get_logger
|
||||
from ..services.background_task_manager import get_task_manager
|
||||
from ..services.projects.project_service import ProjectService
|
||||
from ..services.projects.source_linking_service import SourceLinkingService
|
||||
from ..socketio_app import get_socketio_instance
|
||||
from ..socketio_app import sio
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Get Socket.IO instance
|
||||
sio = get_socketio_instance()
|
||||
logger.info(f"🔗 [SOCKETIO] Socket.IO instance ID: {id(sio)}")
|
||||
|
||||
# Rate limiting for Socket.IO broadcasts
|
||||
_last_broadcast_times: dict[str, float] = {}
|
||||
|
||||
@@ -128,7 +128,7 @@ class KnowledgeItemService:
|
||||
code_example_counts[source_id] = 0
|
||||
chunk_counts[source_id] = 0 # Default to 0 to avoid timeout
|
||||
|
||||
safe_logfire_info(f"Code example counts: {code_example_counts}")
|
||||
safe_logfire_info("Code example counts", code_counts=code_example_counts)
|
||||
|
||||
# Transform sources to items with batched data
|
||||
items = []
|
||||
@@ -138,16 +138,18 @@ class KnowledgeItemService:
|
||||
|
||||
# Use batched data instead of individual queries
|
||||
first_page_url = first_urls.get(source_id, f"source://{source_id}")
|
||||
# Use original crawl URL instead of first page URL
|
||||
original_url = source_metadata.get("original_url") or first_page_url
|
||||
code_examples_count = code_example_counts.get(source_id, 0)
|
||||
chunks_count = chunk_counts.get(source_id, 0)
|
||||
|
||||
# Determine source type
|
||||
source_type = self._determine_source_type(source_metadata, first_page_url)
|
||||
source_type = self._determine_source_type(source_metadata, original_url)
|
||||
|
||||
item = {
|
||||
"id": source_id,
|
||||
"title": source.get("title", source.get("summary", "Untitled")),
|
||||
"url": first_page_url,
|
||||
"url": original_url,
|
||||
"source_id": source_id,
|
||||
"code_examples": [{"count": code_examples_count}]
|
||||
if code_examples_count > 0
|
||||
|
||||
@@ -11,13 +11,10 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ...config.logfire_config import get_logger
|
||||
from ...socketio_app import get_socketio_instance
|
||||
from ...socketio_app import sio
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Get Socket.IO instance
|
||||
sio = get_socketio_instance()
|
||||
logger.info(f"🔗 [PROGRESS] Socket.IO instance ID: {id(sio)}")
|
||||
|
||||
|
||||
class ProgressService:
|
||||
|
||||
@@ -17,9 +17,7 @@ logger = get_logger(__name__)
|
||||
|
||||
# Import Socket.IO instance directly to avoid circular imports
|
||||
try:
|
||||
from ...socketio_app import get_socketio_instance
|
||||
|
||||
_sio = get_socketio_instance()
|
||||
from ...socketio_app import sio as _sio
|
||||
_broadcast_available = True
|
||||
logger.info("✅ Socket.IO broadcasting is AVAILABLE - real-time updates enabled")
|
||||
|
||||
|
||||
@@ -870,14 +870,17 @@ async def add_code_examples_to_supabase(
|
||||
|
||||
# Prepare batch data - only for successful embeddings
|
||||
batch_data = []
|
||||
used_indices = set() # Track which indices have been mapped to prevent duplicates
|
||||
|
||||
for j, (embedding, text) in enumerate(
|
||||
zip(valid_embeddings, successful_texts, strict=False)
|
||||
):
|
||||
# Find the original index
|
||||
# Find the original index (skip already used indices)
|
||||
orig_idx = None
|
||||
for k, orig_text in enumerate(batch_texts):
|
||||
if orig_text == text:
|
||||
if orig_text == text and k not in used_indices:
|
||||
orig_idx = k
|
||||
used_indices.add(k) # Mark this index as used
|
||||
break
|
||||
|
||||
if orig_idx is None:
|
||||
|
||||
@@ -252,20 +252,23 @@ async def add_documents_to_supabase(
|
||||
search_logger.warning(
|
||||
f"Skipping batch {batch_num} - no successful embeddings created"
|
||||
)
|
||||
completed_batches += 1
|
||||
# Don't increment completed_batches when skipping - this causes progress to jump
|
||||
continue
|
||||
|
||||
# Prepare batch data - only for successful embeddings
|
||||
batch_data = []
|
||||
used_indices = set() # Track which indices have been mapped to prevent duplicates
|
||||
|
||||
# Map successful texts back to their original indices
|
||||
for j, (embedding, text) in enumerate(
|
||||
zip(batch_embeddings, successful_texts, strict=False)
|
||||
):
|
||||
# Find the original index of this text
|
||||
# Find the original index of this text (skip already used indices)
|
||||
orig_idx = None
|
||||
for idx, orig_text in enumerate(contextual_contents):
|
||||
if orig_text == text:
|
||||
if orig_text == text and idx not in used_indices:
|
||||
orig_idx = idx
|
||||
used_indices.add(idx) # Mark this index as used
|
||||
break
|
||||
|
||||
if orig_idx is None:
|
||||
@@ -356,6 +359,9 @@ async def add_documents_to_supabase(
|
||||
search_logger.info(
|
||||
f"Individual inserts: {successful_inserts}/{len(batch_data)} successful"
|
||||
)
|
||||
# Even if we had to fall back to individual inserts, count this batch as processed
|
||||
if successful_inserts > 0:
|
||||
completed_batches += 1
|
||||
|
||||
# Minimal delay between batches to prevent overwhelming
|
||||
if i + batch_size < len(contents):
|
||||
|
||||
@@ -93,17 +93,18 @@ class RateLimiter:
|
||||
self._clean_old_entries(now)
|
||||
|
||||
# Check if we can make the request
|
||||
if not self._can_make_request(estimated_tokens):
|
||||
while 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 (tokens={estimated_tokens}, current_usage={self._get_current_usage()})"
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
return await self.acquire(estimated_tokens)
|
||||
return False
|
||||
# Clean old entries after waiting
|
||||
now = time.time()
|
||||
self._clean_old_entries(now)
|
||||
else:
|
||||
return False
|
||||
|
||||
# Record the request
|
||||
self.request_times.append(now)
|
||||
@@ -198,17 +199,13 @@ class MemoryAdaptiveDispatcher:
|
||||
# Reduce workers when memory is high
|
||||
workers = max(1, base // 2)
|
||||
logfire_logger.warning(
|
||||
"High memory usage detected, reducing workers",
|
||||
memory_percent=metrics.memory_percent,
|
||||
workers=workers,
|
||||
f"High memory usage detected, reducing workers (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(
|
||||
"High CPU usage detected, reducing workers",
|
||||
cpu_percent=metrics.cpu_percent,
|
||||
workers=workers,
|
||||
f"High CPU usage detected, reducing workers (cpu_percent={metrics.cpu_percent}, workers={workers})"
|
||||
)
|
||||
elif metrics.memory_percent < 50 and metrics.cpu_percent < 50:
|
||||
# Increase workers when resources are available
|
||||
@@ -238,12 +235,7 @@ class MemoryAdaptiveDispatcher:
|
||||
semaphore = asyncio.Semaphore(optimal_workers)
|
||||
|
||||
logfire_logger.info(
|
||||
"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,
|
||||
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})"
|
||||
)
|
||||
|
||||
# Track active workers
|
||||
@@ -318,7 +310,7 @@ 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} (error={str(e)}, item_index={index})"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -333,11 +325,7 @@ class MemoryAdaptiveDispatcher:
|
||||
|
||||
success_rate = len(successful_results) / len(items) * 100
|
||||
logfire_logger.info(
|
||||
"Adaptive processing completed",
|
||||
total_items=len(items),
|
||||
successful=len(successful_results),
|
||||
success_rate=f"{success_rate:.1f}%",
|
||||
workers_used=optimal_workers,
|
||||
f"Adaptive processing completed (total_items={len(items)}, successful={len(successful_results)}, success_rate={success_rate:.1f}%, workers_used={optimal_workers})"
|
||||
)
|
||||
|
||||
return successful_results
|
||||
@@ -355,7 +343,7 @@ class WebSocketSafeProcessor:
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
logfire_logger.info(
|
||||
"WebSocket client connected", total_connections=len(self.active_connections)
|
||||
f"WebSocket client connected (total_connections={len(self.active_connections)})"
|
||||
)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
@@ -363,7 +351,7 @@ class WebSocketSafeProcessor:
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
logfire_logger.info(
|
||||
"WebSocket client disconnected", remaining_connections=len(self.active_connections)
|
||||
f"WebSocket client disconnected (remaining_connections={len(self.active_connections)})"
|
||||
)
|
||||
|
||||
async def broadcast_progress(self, message: dict[str, Any]):
|
||||
@@ -474,7 +462,7 @@ class ThreadingService:
|
||||
|
||||
self._running = True
|
||||
self._health_check_task = asyncio.create_task(self._health_check_loop())
|
||||
logfire_logger.info("Threading service started", config=self.config.__dict__)
|
||||
logfire_logger.info(f"Threading service started (config={self.config.__dict__})")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the threading service"""
|
||||
@@ -510,7 +498,7 @@ class ThreadingService:
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
logfire_logger.debug(
|
||||
"Rate limited operation completed", duration=duration, tokens=estimated_tokens
|
||||
f"Rate limited operation completed (duration={duration}, tokens={estimated_tokens})"
|
||||
)
|
||||
|
||||
async def run_cpu_intensive(self, func: Callable, *args, **kwargs) -> Any:
|
||||
@@ -562,37 +550,30 @@ class ThreadingService:
|
||||
|
||||
# Log system metrics
|
||||
logfire_logger.info(
|
||||
"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),
|
||||
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)})"
|
||||
)
|
||||
|
||||
# Alert on critical thresholds
|
||||
if metrics.memory_percent > 90:
|
||||
logfire_logger.warning(
|
||||
"Critical memory usage", memory_percent=metrics.memory_percent
|
||||
f"Critical memory usage (memory_percent={metrics.memory_percent})"
|
||||
)
|
||||
# Force garbage collection
|
||||
gc.collect()
|
||||
|
||||
if metrics.cpu_percent > 95:
|
||||
logfire_logger.warning("Critical CPU usage", cpu_percent=metrics.cpu_percent)
|
||||
logfire_logger.warning(f"Critical CPU usage (cpu_percent={metrics.cpu_percent})")
|
||||
|
||||
# Check for memory leaks (too many threads)
|
||||
if metrics.active_threads > self.config.max_workers * 3:
|
||||
logfire_logger.warning(
|
||||
"High thread count detected",
|
||||
active_threads=metrics.active_threads,
|
||||
max_expected=self.config.max_workers * 3,
|
||||
f"High thread count detected (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("Health check failed", error=str(e))
|
||||
logfire_logger.error(f"Health check failed (error={str(e)})")
|
||||
await asyncio.sleep(self.config.health_check_interval)
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ sio = socketio.AsyncServer(
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user