mirror of
https://github.com/coleam00/Archon.git
synced 2026-01-06 22:58:16 -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/kit": "^7.5.0",
|
||||||
"@milkdown/plugin-history": "^7.5.0",
|
"@milkdown/plugin-history": "^7.5.0",
|
||||||
"@milkdown/preset-commonmark": "^7.5.0",
|
"@milkdown/preset-commonmark": "^7.5.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@xyflow/react": "^12.3.0",
|
"@xyflow/react": "^12.3.0",
|
||||||
"clsx": "latest",
|
"clsx": "latest",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "latest",
|
"tailwind-merge": "latest",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.25.46"
|
"zod": "^3.25.46"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2977,6 +2979,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "5.62.0",
|
"version": "5.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||||
@@ -10025,6 +10033,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"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/kit": "^7.5.0",
|
||||||
"@milkdown/plugin-history": "^7.5.0",
|
"@milkdown/plugin-history": "^7.5.0",
|
||||||
"@milkdown/preset-commonmark": "^7.5.0",
|
"@milkdown/preset-commonmark": "^7.5.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@xyflow/react": "^12.3.0",
|
"@xyflow/react": "^12.3.0",
|
||||||
"clsx": "latest",
|
"clsx": "latest",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "latest",
|
"tailwind-merge": "latest",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.25.46"
|
"zod": "^3.25.46"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 [showPageTooltip, setShowPageTooltip] = useState(false);
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = 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 [isLoadingCodeExamples, setIsLoadingCodeExamples] = useState(false);
|
||||||
|
|
||||||
const statusColorMap = {
|
const statusColorMap = {
|
||||||
|
|||||||
@@ -601,21 +601,18 @@ export const DocsTab = ({
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
// Create a new document with a unique ID
|
// Create document via backend API
|
||||||
const newDocument: ProjectDoc = {
|
const newDocument = await projectService.createDocument(project.id, {
|
||||||
id: `doc-${Date.now()}`,
|
|
||||||
title: template.name,
|
title: template.name,
|
||||||
content: template.content,
|
content: template.content,
|
||||||
document_type: template.document_type,
|
document_type: template.document_type
|
||||||
created_at: new Date().toISOString(),
|
});
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add to documents list
|
// Add to documents list
|
||||||
setDocuments(prev => [...prev, newDocument]);
|
setDocuments(prev => [...prev, newDocument]);
|
||||||
setSelectedDocument(newDocument);
|
setSelectedDocument(newDocument);
|
||||||
|
|
||||||
console.log('Document created successfully:', newDocument);
|
console.log('Document created successfully via API:', newDocument);
|
||||||
showToast('Document created successfully', 'success');
|
showToast('Document created successfully', 'success');
|
||||||
setShowTemplateModal(false);
|
setShowTemplateModal(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -636,18 +633,19 @@ export const DocsTab = ({
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
// Update the document in local state
|
// Update the document via backend API
|
||||||
const updatedDocument = {
|
const updatedDocument = await projectService.updateDocument(project.id, selectedDocument.id, {
|
||||||
...selectedDocument,
|
...selectedDocument,
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// Update local state with the response from backend
|
||||||
setDocuments(prev => prev.map(doc =>
|
setDocuments(prev => prev.map(doc =>
|
||||||
doc.id === selectedDocument.id ? updatedDocument : doc
|
doc.id === selectedDocument.id ? updatedDocument : doc
|
||||||
));
|
));
|
||||||
setSelectedDocument(updatedDocument);
|
setSelectedDocument(updatedDocument);
|
||||||
|
|
||||||
console.log('Document saved successfully:', updatedDocument);
|
console.log('Document saved successfully via API:', updatedDocument);
|
||||||
showToast('Document saved successfully', 'success');
|
showToast('Document saved successfully', 'success');
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -938,6 +936,8 @@ export const DocsTab = ({
|
|||||||
isActive={selectedDocument?.id === doc.id}
|
isActive={selectedDocument?.id === doc.id}
|
||||||
onSelect={setSelectedDocument}
|
onSelect={setSelectedDocument}
|
||||||
onDelete={async (docId) => {
|
onDelete={async (docId) => {
|
||||||
|
if (!project?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call API to delete from database first
|
// Call API to delete from database first
|
||||||
await projectService.deleteDocument(project.id, docId);
|
await projectService.deleteDocument(project.id, docId);
|
||||||
@@ -981,22 +981,24 @@ export const DocsTab = ({
|
|||||||
document={selectedDocument}
|
document={selectedDocument}
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
onSave={async (updatedDocument) => {
|
onSave={async (updatedDocument) => {
|
||||||
|
if (!project?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
// Update document with timestamp
|
// Update document via backend API
|
||||||
const docWithTimestamp = {
|
const savedDocument = await projectService.updateDocument(project.id, updatedDocument.id, {
|
||||||
...updatedDocument,
|
...updatedDocument,
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
};
|
});
|
||||||
|
|
||||||
// Update local state
|
// Update local state with the response from backend
|
||||||
setSelectedDocument(docWithTimestamp);
|
setSelectedDocument(savedDocument);
|
||||||
setDocuments(prev => prev.map(doc =>
|
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');
|
showToast('Document saved successfully', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save document:', error);
|
console.error('Failed to save document:', error);
|
||||||
@@ -1190,7 +1192,7 @@ const TemplateModal: React.FC<{
|
|||||||
const KnowledgeSection: React.FC<{
|
const KnowledgeSection: React.FC<{
|
||||||
title: string;
|
title: string;
|
||||||
color: 'blue' | 'purple' | 'pink' | 'orange';
|
color: 'blue' | 'purple' | 'pink' | 'orange';
|
||||||
sources: any[];
|
sources: Array<{id: string; title: string; type: string; lastUpdated: string} | undefined>;
|
||||||
onAddClick: () => void;
|
onAddClick: () => void;
|
||||||
}> = ({
|
}> = ({
|
||||||
title,
|
title,
|
||||||
@@ -1273,7 +1275,7 @@ const KnowledgeSection: React.FC<{
|
|||||||
|
|
||||||
const SourceSelectionModal: React.FC<{
|
const SourceSelectionModal: React.FC<{
|
||||||
title: string;
|
title: string;
|
||||||
sources: any[];
|
sources: Array<{id: string; title: string; type: string; lastUpdated: string}>;
|
||||||
selectedSources: string[];
|
selectedSources: string[];
|
||||||
onToggleSource: (id: string) => void;
|
onToggleSource: (id: string) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Rocket, Code, Briefcase, Users, FileText, X, Plus, Clipboard } from 'lucide-react';
|
import { Rocket, Code, Briefcase, Users, FileText, X, Plus, Clipboard } from 'lucide-react';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { copyToClipboard } from '../../utils/clipboard';
|
||||||
|
|
||||||
export interface ProjectDoc {
|
export interface ProjectDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,9 +50,10 @@ export const DocumentCard: React.FC<DocumentCardProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyId = (e: React.MouseEvent) => {
|
const handleCopyId = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(document.id);
|
const success = await copyToClipboard(document.id);
|
||||||
|
if (success) {
|
||||||
showToast('Document ID copied to clipboard', 'success');
|
showToast('Document ID copied to clipboard', 'success');
|
||||||
|
|
||||||
// Visual feedback
|
// Visual feedback
|
||||||
@@ -61,6 +63,9 @@ export const DocumentCard: React.FC<DocumentCardProps> = ({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.innerHTML = originalHTML;
|
button.innerHTML = originalHTML;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showToast('Failed to copy Document ID', 'error');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useDrag, useDrop } from 'react-dnd';
|
|||||||
import { Edit, Trash2, RefreshCw, Tag, User, Bot, Clipboard } from 'lucide-react';
|
import { Edit, Trash2, RefreshCw, Tag, User, Bot, Clipboard } from 'lucide-react';
|
||||||
import { Task } from './TaskTableView';
|
import { Task } from './TaskTableView';
|
||||||
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
|
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
|
||||||
|
import { copyToClipboard } from '../../utils/clipboard';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
|
||||||
export interface DraggableTaskCardProps {
|
export interface DraggableTaskCardProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -27,6 +29,7 @@ export const DraggableTaskCard = ({
|
|||||||
hoveredTaskId,
|
hoveredTaskId,
|
||||||
onTaskHover,
|
onTaskHover,
|
||||||
}: DraggableTaskCardProps) => {
|
}: DraggableTaskCardProps) => {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const [{ isDragging }, drag] = useDrag({
|
const [{ isDragging }, drag] = useDrag({
|
||||||
type: ItemTypes.TASK,
|
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>
|
<span className="text-gray-600 dark:text-gray-400 text-xs">{task.assignee?.name || 'User'}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(task.id);
|
const success = await copyToClipboard(task.id);
|
||||||
// Optional: Add a small toast or visual feedback here
|
if (success) {
|
||||||
|
showToast('Task ID copied to clipboard', 'success');
|
||||||
|
// Visual feedback
|
||||||
const button = e.currentTarget;
|
const button = e.currentTarget;
|
||||||
const originalHTML = button.innerHTML;
|
const originalHTML = button.innerHTML;
|
||||||
button.innerHTML = '<span class="text-green-500">Copied!</span>';
|
button.innerHTML = '<span class="text-green-500">Copied!</span>';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.innerHTML = originalHTML;
|
button.innerHTML = originalHTML;
|
||||||
}, 2000);
|
}, 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"
|
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"
|
title="Copy Task ID to clipboard"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Task } from './TaskTableView';
|
|||||||
interface EditTaskModalProps {
|
interface EditTaskModalProps {
|
||||||
isModalOpen: boolean;
|
isModalOpen: boolean;
|
||||||
editingTask: Task | null;
|
editingTask: Task | null;
|
||||||
projectFeatures: any[];
|
projectFeatures: import('../types/jsonb').ProjectFeature[];
|
||||||
isLoadingFeatures: boolean;
|
isLoadingFeatures: boolean;
|
||||||
isSavingTask: boolean;
|
isSavingTask: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ interface FeaturesTabProps {
|
|||||||
project?: {
|
project?: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
features?: any[];
|
features?: import('../types/jsonb').ProjectFeature[];
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ DebouncedInput.displayName = 'DebouncedInput';
|
|||||||
interface FeatureInputProps {
|
interface FeatureInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
projectFeatures: any[];
|
projectFeatures: import('../types/jsonb').ProjectFeature[];
|
||||||
isLoadingFeatures: boolean;
|
isLoadingFeatures: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { DeleteConfirmModal } from '../../pages/ProjectPage';
|
|||||||
import { projectService } from '../../services/projectService';
|
import { projectService } from '../../services/projectService';
|
||||||
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
|
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
|
||||||
import { DraggableTaskCard } from './DraggableTaskCard';
|
import { DraggableTaskCard } from './DraggableTaskCard';
|
||||||
|
import { copyToClipboard } from '../../utils/clipboard';
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,7 +14,7 @@ export interface Task {
|
|||||||
description: string;
|
description: string;
|
||||||
status: 'backlog' | 'in-progress' | 'review' | 'complete';
|
status: 'backlog' | 'in-progress' | 'review' | 'complete';
|
||||||
assignee: {
|
assignee: {
|
||||||
name: 'User' | 'Archon' | 'AI IDE Agent';
|
name: string; // Allow any assignee name for MCP subagents
|
||||||
avatar: string;
|
avatar: string;
|
||||||
};
|
};
|
||||||
feature: string;
|
feature: string;
|
||||||
@@ -31,7 +32,7 @@ interface TaskTableViewProps {
|
|||||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
|
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAssigneeGlassStyle = (assigneeName: 'User' | 'Archon' | 'AI IDE Agent') => {
|
const getAssigneeGlassStyle = (assigneeName: string) => {
|
||||||
switch (assigneeName) {
|
switch (assigneeName) {
|
||||||
case 'User':
|
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
|
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) => {
|
}: DraggableTaskRowProps) => {
|
||||||
const [editingField, setEditingField] = useState<string | null>(null);
|
const [editingField, setEditingField] = useState<string | null>(null);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const [{ isDragging }, drag] = useDrag({
|
const [{ isDragging }, drag] = useDrag({
|
||||||
type: ItemTypes.TASK,
|
type: ItemTypes.TASK,
|
||||||
@@ -252,7 +254,7 @@ const DraggableTaskRow = ({
|
|||||||
} else if (field === 'status') {
|
} else if (field === 'status') {
|
||||||
updates.status = value as Task['status'];
|
updates.status = value as Task['status'];
|
||||||
} else if (field === 'assignee') {
|
} 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') {
|
} else if (field === 'feature') {
|
||||||
updates.feature = value;
|
updates.feature = value;
|
||||||
}
|
}
|
||||||
@@ -336,32 +338,14 @@ const DraggableTaskRow = ({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex items-center justify-center">
|
<EditableCell
|
||||||
<div
|
value={task.assignee?.name || 'AI IDE Agent'}
|
||||||
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')}`}
|
onSave={(value) => handleUpdateField('assignee', value || 'AI IDE Agent')}
|
||||||
onClick={() => setEditingField('assignee')}
|
isEditing={editingField === 'assignee'}
|
||||||
title={`Assignee: ${task.assignee?.name || 'User'}`}
|
onEdit={() => setEditingField('assignee')}
|
||||||
>
|
onCancel={() => setEditingField(null)}
|
||||||
{getAssigneeIcon(task.assignee?.name || 'User')}
|
placeholder="AI IDE Agent"
|
||||||
</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>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
@@ -385,9 +369,11 @@ const DraggableTaskRow = ({
|
|||||||
</button>
|
</button>
|
||||||
{/* Copy Task ID Button - Matching Board View */}
|
{/* Copy Task ID Button - Matching Board View */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(task.id);
|
const success = await copyToClipboard(task.id);
|
||||||
|
if (success) {
|
||||||
|
showToast('Task ID copied to clipboard', 'success');
|
||||||
// Visual feedback like in board view
|
// Visual feedback like in board view
|
||||||
const button = e.currentTarget;
|
const button = e.currentTarget;
|
||||||
const originalHTML = button.innerHTML;
|
const originalHTML = button.innerHTML;
|
||||||
@@ -395,6 +381,9 @@ const DraggableTaskRow = ({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.innerHTML = originalHTML;
|
button.innerHTML = originalHTML;
|
||||||
}, 2000);
|
}, 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"
|
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"
|
title="Copy Task ID to clipboard"
|
||||||
@@ -524,18 +513,17 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<select
|
<input
|
||||||
|
type="text"
|
||||||
value={newTask.assignee.name}
|
value={newTask.assignee.name}
|
||||||
onChange={(e) => setNewTask(prev => ({
|
onChange={(e) => setNewTask(prev => ({
|
||||||
...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)]"
|
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>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { DndProvider } from 'react-dnd';
|
|||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import { Toggle } from '../ui/Toggle';
|
import { Toggle } from '../ui/Toggle';
|
||||||
import { projectService } from '../../services/projectService';
|
import { projectService } from '../../services/projectService';
|
||||||
|
import { getGlobalOperationTracker } from '../../utils/operationTracker';
|
||||||
|
|
||||||
import { useTaskSocket } from '../../hooks/useTaskSocket';
|
import { useTaskSocket } from '../../hooks/useTaskSocket';
|
||||||
import type { CreateTaskRequest, UpdateTaskRequest, DatabaseTaskStatus } from '../../types/project';
|
import type { CreateTaskRequest, UpdateTaskRequest, DatabaseTaskStatus } from '../../types/project';
|
||||||
|
import { WebSocketState } from '../../services/socketIOService';
|
||||||
import { TaskTableView, Task } from './TaskTableView';
|
import { TaskTableView, Task } from './TaskTableView';
|
||||||
import { TaskBoardView } from './TaskBoardView';
|
import { TaskBoardView } from './TaskBoardView';
|
||||||
import { EditTaskModal } from './EditTaskModal';
|
import { EditTaskModal } from './EditTaskModal';
|
||||||
@@ -65,11 +67,14 @@ export const TasksTab = ({
|
|||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [projectFeatures, setProjectFeatures] = useState<any[]>([]);
|
const [projectFeatures, setProjectFeatures] = useState<import('../types/jsonb').ProjectFeature[]>([]);
|
||||||
const [isLoadingFeatures, setIsLoadingFeatures] = useState(false);
|
const [isLoadingFeatures, setIsLoadingFeatures] = useState(false);
|
||||||
const [isSavingTask, setIsSavingTask] = useState<boolean>(false);
|
const [isSavingTask, setIsSavingTask] = useState<boolean>(false);
|
||||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||||
|
|
||||||
|
// Track recently deleted tasks to prevent race conditions
|
||||||
|
const [recentlyDeletedIds, setRecentlyDeletedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Initialize tasks
|
// Initialize tasks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTasks(initialTasks);
|
setTasks(initialTasks);
|
||||||
@@ -85,6 +90,12 @@ export const TasksTab = ({
|
|||||||
const updatedTask = message.data || message;
|
const updatedTask = message.data || message;
|
||||||
const mappedTask = mapDatabaseTaskToUITask(updatedTask);
|
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
|
// Skip updates while modal is open for the same task to prevent conflicts
|
||||||
if (isModalOpen && editingTask?.id === updatedTask.id) {
|
if (isModalOpen && editingTask?.id === updatedTask.id) {
|
||||||
console.log('[Socket] Skipping update for task being edited:', updatedTask.id);
|
console.log('[Socket] Skipping update for task being edited:', updatedTask.id);
|
||||||
@@ -94,20 +105,15 @@ export const TasksTab = ({
|
|||||||
setTasks(prev => {
|
setTasks(prev => {
|
||||||
// Use server timestamp for conflict resolution
|
// Use server timestamp for conflict resolution
|
||||||
const existingTask = prev.find(task => task.id === updatedTask.id);
|
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) {
|
// Skip if we already have this task (prevent duplicate additions)
|
||||||
console.log('[Socket] Ignoring stale update for task:', updatedTask.id);
|
if (!existingTask) {
|
||||||
return prev;
|
console.log('[Socket] Task not found locally, adding:', updatedTask.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = prev.map(task =>
|
const updated = prev.map(task =>
|
||||||
task.id === updatedTask.id
|
task.id === updatedTask.id
|
||||||
? { ...mappedTask, lastUpdate: message.server_timestamp || Date.now() }
|
? { ...mappedTask }
|
||||||
: task
|
: task
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,7 +121,7 @@ export const TasksTab = ({
|
|||||||
setTimeout(() => onTasksChange(updated), 0);
|
setTimeout(() => onTasksChange(updated), 0);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}, [onTasksChange, isModalOpen, editingTask?.id]);
|
}, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds]);
|
||||||
|
|
||||||
const handleTaskCreated = useCallback((message: any) => {
|
const handleTaskCreated = useCallback((message: any) => {
|
||||||
const newTask = message.data || message;
|
const newTask = message.data || message;
|
||||||
@@ -123,11 +129,27 @@ export const TasksTab = ({
|
|||||||
const mappedTask = mapDatabaseTaskToUITask(newTask);
|
const mappedTask = mapDatabaseTaskToUITask(newTask);
|
||||||
|
|
||||||
setTasks(prev => {
|
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
|
// Check if task already exists to prevent duplicates
|
||||||
if (prev.some(task => task.id === newTask.id)) {
|
if (prev.some(task => task.id === newTask.id)) {
|
||||||
console.log('Task already exists, skipping create');
|
console.log('Task already exists, skipping create');
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = [...prev, mappedTask];
|
const updated = [...prev, mappedTask];
|
||||||
setTimeout(() => onTasksChange(updated), 0);
|
setTimeout(() => onTasksChange(updated), 0);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -137,6 +159,14 @@ export const TasksTab = ({
|
|||||||
const handleTaskDeleted = useCallback((message: any) => {
|
const handleTaskDeleted = useCallback((message: any) => {
|
||||||
const deletedTask = message.data || message;
|
const deletedTask = message.data || message;
|
||||||
console.log('🗑️ Real-time task deleted:', deletedTask);
|
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 => {
|
setTasks(prev => {
|
||||||
const updated = prev.filter(task => task.id !== deletedTask.id);
|
const updated = prev.filter(task => task.id !== deletedTask.id);
|
||||||
setTimeout(() => onTasksChange(updated), 0);
|
setTimeout(() => onTasksChange(updated), 0);
|
||||||
@@ -183,7 +213,7 @@ export const TasksTab = ({
|
|||||||
onTasksReordered: handleTasksReordered,
|
onTasksReordered: handleTasksReordered,
|
||||||
onInitialTasks: handleInitialTasks,
|
onInitialTasks: handleInitialTasks,
|
||||||
onConnectionStateChange: (state) => {
|
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(
|
const debouncedPersistSingleTask = useMemo(
|
||||||
() => debounce(async (task: Task) => {
|
() => debounce(async (task: Task) => {
|
||||||
try {
|
try {
|
||||||
console.log('REORDER: Persisting position change for task:', task.title, 'new position:', task.task_order);
|
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, {
|
await projectService.updateTask(task.id, {
|
||||||
task_order: task.task_order,
|
task_order: task.task_order
|
||||||
client_timestamp: Date.now()
|
|
||||||
});
|
});
|
||||||
console.log('REORDER: Single task position persisted successfully');
|
console.log('REORDER: Single task position persisted successfully');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('REORDER: Failed to persist task position:', 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');
|
console.log('REORDER: Socket will handle state recovery');
|
||||||
}
|
}
|
||||||
}, 800), // Slightly reduced delay for better responsiveness
|
}, 800),
|
||||||
[projectId]
|
[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']) => {
|
const handleTaskReorder = useCallback((taskId: string, targetIndex: number, status: Task['status']) => {
|
||||||
console.log('REORDER: Moving task', taskId, 'to index', targetIndex, 'in status', status);
|
console.log('REORDER: Moving task', taskId, 'to index', targetIndex, 'in status', status);
|
||||||
|
|
||||||
@@ -357,63 +411,37 @@ export const TasksTab = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const movingTask = statusTasks[movingTaskIndex];
|
console.log('REORDER: Moving task from position', movingTaskIndex, 'to', targetIndex);
|
||||||
console.log('REORDER: Moving', movingTask.title, 'from', movingTaskIndex, 'to', targetIndex);
|
|
||||||
|
|
||||||
// Calculate new position using improved algorithm
|
// Remove the task from its current position and insert at target position
|
||||||
let newPosition: number;
|
const reorderedTasks = [...statusTasks];
|
||||||
|
const [movedTask] = reorderedTasks.splice(movingTaskIndex, 1);
|
||||||
|
reorderedTasks.splice(targetIndex, 0, movedTask);
|
||||||
|
|
||||||
if (targetIndex === 0) {
|
// Assign sequential order numbers (1, 2, 3, etc.) to all tasks in this status
|
||||||
// Moving to first position
|
const updatedStatusTasks = reorderedTasks.map((task, index) => ({
|
||||||
const firstTask = statusTasks[0];
|
...task,
|
||||||
newPosition = firstTask.task_order / 2;
|
task_order: index + 1,
|
||||||
} else if (targetIndex === statusTasks.length - 1) {
|
lastUpdate: Date.now()
|
||||||
// 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) {
|
console.log('REORDER: New order:', updatedStatusTasks.map(t => `${t.title}:${t.task_order}`));
|
||||||
// Moving down
|
|
||||||
prevTask = statusTasks[targetIndex];
|
|
||||||
nextTask = statusTasks[targetIndex + 1];
|
|
||||||
} else {
|
|
||||||
// Moving up
|
|
||||||
prevTask = statusTasks[targetIndex - 1];
|
|
||||||
nextTask = statusTasks[targetIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevTask && nextTask) {
|
// Update UI immediately with all reordered tasks
|
||||||
newPosition = (prevTask.task_order + nextTask.task_order) / 2;
|
const allUpdatedTasks = [...otherTasks, ...updatedStatusTasks];
|
||||||
} else if (prevTask) {
|
|
||||||
newPosition = prevTask.task_order + 1024;
|
|
||||||
} else if (nextTask) {
|
|
||||||
newPosition = nextTask.task_order / 2;
|
|
||||||
} else {
|
|
||||||
newPosition = 1024; // Fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('REORDER: New position calculated:', newPosition);
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
);
|
|
||||||
updateTasks(allUpdatedTasks);
|
updateTasks(allUpdatedTasks);
|
||||||
|
|
||||||
// Persist to backend (single API call)
|
// Batch update to backend - only update tasks that changed position
|
||||||
debouncedPersistSingleTask(updatedTask);
|
const tasksToUpdate = updatedStatusTasks.filter((task, index) => {
|
||||||
}, [tasks, updateTasks, debouncedPersistSingleTask]);
|
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)
|
// Task move function (for board view)
|
||||||
const moveTask = async (taskId: string, newStatus: Task['status']) => {
|
const moveTask = async (taskId: string, newStatus: Task['status']) => {
|
||||||
@@ -433,8 +461,7 @@ export const TasksTab = ({
|
|||||||
// Update the task with new status and order
|
// Update the task with new status and order
|
||||||
await projectService.updateTask(taskId, {
|
await projectService.updateTask(taskId, {
|
||||||
status: mapUIStatusToDBStatus(newStatus),
|
status: mapUIStatusToDBStatus(newStatus),
|
||||||
task_order: newOrder,
|
task_order: newOrder
|
||||||
client_timestamp: Date.now()
|
|
||||||
});
|
});
|
||||||
console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`);
|
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>) => {
|
const updateTaskInline = async (taskId: string, updates: Partial<Task>) => {
|
||||||
console.log(`[TasksTab] Inline update for task ${taskId} with updates:`, updates);
|
console.log(`[TasksTab] Inline update for task ${taskId} with updates:`, updates);
|
||||||
try {
|
try {
|
||||||
const updateData: Partial<UpdateTaskRequest> = {
|
const updateData: Partial<UpdateTaskRequest> = {};
|
||||||
client_timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (updates.title !== undefined) updateData.title = updates.title;
|
if (updates.title !== undefined) updateData.title = updates.title;
|
||||||
if (updates.description !== undefined) updateData.description = updates.description;
|
if (updates.description !== undefined) updateData.description = updates.description;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Clock, RotateCcw, Eye, Calendar, User, FileText, Diff, GitBranch, Layers, Plus, Minus, AlertTriangle } from 'lucide-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 { Button } from '../ui/Button';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function formatSectionContent(value: any): string {
|
|||||||
/**
|
/**
|
||||||
* Formats array content as markdown list or nested structure
|
* Formats array content as markdown list or nested structure
|
||||||
*/
|
*/
|
||||||
function formatArrayContent(array: any[]): string {
|
function formatArrayContent(array: unknown[]): string {
|
||||||
if (array.length === 0) {
|
if (array.length === 0) {
|
||||||
return '_No items_';
|
return '_No items_';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface PRPPersona {
|
|||||||
export interface PRPPhase {
|
export interface PRPPhase {
|
||||||
duration?: string;
|
duration?: string;
|
||||||
deliverables?: string[];
|
deliverables?: string[];
|
||||||
tasks?: any[];
|
tasks?: Array<{title: string; files: string[]; details: string}>;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DocsTab } from '../components/project-tasks/DocsTab';
|
|||||||
import { TasksTab } from '../components/project-tasks/TasksTab';
|
import { TasksTab } from '../components/project-tasks/TasksTab';
|
||||||
import { Button } from '../components/ui/Button';
|
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 { 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 our service layer and types
|
||||||
import { projectService } from '../services/projectService';
|
import { projectService } from '../services/projectService';
|
||||||
@@ -844,9 +845,10 @@ export function ProjectPage({
|
|||||||
|
|
||||||
{/* Copy Project ID Button */}
|
{/* Copy Project ID Button */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(project.id);
|
const success = await copyToClipboard(project.id);
|
||||||
|
if (success) {
|
||||||
showToast('Project ID copied to clipboard', 'success');
|
showToast('Project ID copied to clipboard', 'success');
|
||||||
// Visual feedback
|
// Visual feedback
|
||||||
const button = e.currentTarget;
|
const button = e.currentTarget;
|
||||||
@@ -855,6 +857,9 @@ export function ProjectPage({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.innerHTML = originalHTML;
|
button.innerHTML = originalHTML;
|
||||||
}, 2000);
|
}, 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"
|
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"
|
title="Copy Project ID to clipboard"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Brain,
|
Brain,
|
||||||
Code,
|
Code,
|
||||||
Activity,
|
|
||||||
FileCode,
|
FileCode,
|
||||||
Bug,
|
Bug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -20,7 +19,6 @@ import { FeaturesSection } from "../components/settings/FeaturesSection";
|
|||||||
import { APIKeysSection } from "../components/settings/APIKeysSection";
|
import { APIKeysSection } from "../components/settings/APIKeysSection";
|
||||||
import { RAGSettings } from "../components/settings/RAGSettings";
|
import { RAGSettings } from "../components/settings/RAGSettings";
|
||||||
import { CodeExtractionSettings } from "../components/settings/CodeExtractionSettings";
|
import { CodeExtractionSettings } from "../components/settings/CodeExtractionSettings";
|
||||||
import { TestStatus } from "../components/settings/TestStatus";
|
|
||||||
import { IDEGlobalRules } from "../components/settings/IDEGlobalRules";
|
import { IDEGlobalRules } from "../components/settings/IDEGlobalRules";
|
||||||
import { ButtonPlayground } from "../components/settings/ButtonPlayground";
|
import { ButtonPlayground } from "../components/settings/ButtonPlayground";
|
||||||
import { CollapsibleSettingsCard } from "../components/ui/CollapsibleSettingsCard";
|
import { CollapsibleSettingsCard } from "../components/ui/CollapsibleSettingsCard";
|
||||||
@@ -151,15 +149,31 @@ export const SettingsPage = () => {
|
|||||||
</CollapsibleSettingsCard>
|
</CollapsibleSettingsCard>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
{/* Bug Report Section - Moved to left column */}
|
||||||
<motion.div variants={itemVariants}>
|
<motion.div variants={itemVariants}>
|
||||||
<CollapsibleSettingsCard
|
<CollapsibleSettingsCard
|
||||||
title="Test Status"
|
title="Bug Reporting"
|
||||||
icon={Activity}
|
icon={Bug}
|
||||||
accentColor="cyan"
|
iconColor="text-red-500"
|
||||||
storageKey="test-status"
|
borderColor="border-red-200 dark:border-red-800"
|
||||||
defaultExpanded={true}
|
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>
|
</CollapsibleSettingsCard>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,34 +219,6 @@ export const SettingsPage = () => {
|
|||||||
/>
|
/>
|
||||||
</CollapsibleSettingsCard>
|
</CollapsibleSettingsCard>
|
||||||
</motion.div>
|
</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>
|
||||||
</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;
|
currentStep?: string;
|
||||||
logs?: string[];
|
logs?: string[];
|
||||||
log?: string;
|
log?: string;
|
||||||
workers?: WorkerProgress[] | any[]; // Updated to support new worker format
|
workers?: WorkerProgress[]; // Updated to support new worker format
|
||||||
error?: string;
|
error?: string;
|
||||||
completed?: boolean;
|
completed?: boolean;
|
||||||
// Additional properties for document upload and crawling
|
// Additional properties for document upload and crawling
|
||||||
@@ -50,6 +50,7 @@ export interface CrawlProgressData {
|
|||||||
wordCount?: number;
|
wordCount?: number;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
|
codeExamplesCount?: number;
|
||||||
// Original crawl parameters for retry functionality
|
// Original crawl parameters for retry functionality
|
||||||
originalCrawlParams?: {
|
originalCrawlParams?: {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -98,7 +99,7 @@ interface StreamProgressOptions {
|
|||||||
connectionTimeout?: number;
|
connectionTimeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProgressCallback = (data: any) => void;
|
type ProgressCallback = (data: CrawlProgressData) => void;
|
||||||
|
|
||||||
class CrawlProgressService {
|
class CrawlProgressService {
|
||||||
private wsService: WebSocketService = knowledgeSocketIO;
|
private wsService: WebSocketService = knowledgeSocketIO;
|
||||||
@@ -116,6 +117,9 @@ class CrawlProgressService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(`🚀 Starting Socket.IO progress stream for ${progressId}`);
|
console.log(`🚀 Starting Socket.IO progress stream for ${progressId}`);
|
||||||
|
|
||||||
|
// Store the active crawl progress ID in localStorage for reconnection
|
||||||
|
localStorage.setItem('activeCrawlProgressId', progressId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure we're connected to Socket.IO
|
// Ensure we're connected to Socket.IO
|
||||||
if (!this.wsService.isConnected()) {
|
if (!this.wsService.isConnected()) {
|
||||||
@@ -141,7 +145,7 @@ class CrawlProgressService {
|
|||||||
}, 5000); // 5 second timeout for acknowledgment
|
}, 5000); // 5 second timeout for acknowledgment
|
||||||
|
|
||||||
// Listen for subscription 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;
|
const data = message.data || message;
|
||||||
console.log(`📨 Received acknowledgment:`, data);
|
console.log(`📨 Received acknowledgment:`, data);
|
||||||
if (data.progress_id === progressId && data.status === 'subscribed') {
|
if (data.progress_id === progressId && data.status === 'subscribed') {
|
||||||
@@ -156,7 +160,7 @@ class CrawlProgressService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a specific handler for this progressId
|
// 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);
|
console.log(`📨 [${progressId}] Raw message received:`, message);
|
||||||
const data = message.data || message;
|
const data = message.data || message;
|
||||||
console.log(`📨 [${progressId}] Extracted data:`, data);
|
console.log(`📨 [${progressId}] Extracted data:`, data);
|
||||||
@@ -185,6 +189,8 @@ class CrawlProgressService {
|
|||||||
console.log(`✅ Crawl completed for ${progressId}`);
|
console.log(`✅ Crawl completed for ${progressId}`);
|
||||||
if (data.progressId === progressId) {
|
if (data.progressId === progressId) {
|
||||||
onMessage({ ...data, completed: true });
|
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',
|
error: message.data?.message || message.error || 'Unknown error',
|
||||||
percentage: 0
|
percentage: 0
|
||||||
});
|
});
|
||||||
|
// Clear the stored progress ID on error
|
||||||
|
localStorage.removeItem('activeCrawlProgressId');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,6 +306,12 @@ class CrawlProgressService {
|
|||||||
|
|
||||||
// Remove from active subscriptions
|
// Remove from active subscriptions
|
||||||
this.activeSubscriptions.delete(progressId);
|
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,
|
progressId: string,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onMessage: ProgressCallback;
|
onMessage: ProgressCallback;
|
||||||
onStateChange?: (state: any) => void;
|
onStateChange?: (state: string) => void;
|
||||||
onError?: (error: any) => void;
|
onError?: (error: Error) => void;
|
||||||
},
|
},
|
||||||
options: StreamProgressOptions = {}
|
options: StreamProgressOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { OperationTracker, OperationResult } from '../utils/operationTracker';
|
||||||
|
|
||||||
export enum WebSocketState {
|
export enum WebSocketState {
|
||||||
CONNECTING = 'CONNECTING',
|
CONNECTING = 'CONNECTING',
|
||||||
@@ -33,9 +34,9 @@ export interface WebSocketConfig {
|
|||||||
|
|
||||||
export interface WebSocketMessage {
|
export interface WebSocketMessage {
|
||||||
type: string;
|
type: string;
|
||||||
data?: any;
|
data?: unknown;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
[key: string]: any;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandler = (message: WebSocketMessage) => void;
|
type MessageHandler = (message: WebSocketMessage) => void;
|
||||||
@@ -57,9 +58,13 @@ export class WebSocketService {
|
|||||||
private _state: WebSocketState = WebSocketState.DISCONNECTED;
|
private _state: WebSocketState = WebSocketState.DISCONNECTED;
|
||||||
|
|
||||||
// Deduplication support
|
// 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
|
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 = {}) {
|
constructor(config: WebSocketConfig = {}) {
|
||||||
this.config = {
|
this.config = {
|
||||||
maxReconnectAttempts: 5,
|
maxReconnectAttempts: 5,
|
||||||
@@ -215,9 +220,9 @@ export class WebSocketService {
|
|||||||
|
|
||||||
this.socket.on('connect_error', (error: Error) => {
|
this.socket.on('connect_error', (error: Error) => {
|
||||||
console.error('❌ Socket.IO connection 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('❌ 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);
|
this.notifyError(error);
|
||||||
|
|
||||||
// Reject connection promise if still pending
|
// Reject connection promise if still pending
|
||||||
@@ -244,13 +249,20 @@ export class WebSocketService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle incoming messages
|
// Handle incoming messages
|
||||||
this.socket.onAny((eventName: string, ...args: any[]) => {
|
this.socket.onAny((eventName: string, ...args: unknown[]) => {
|
||||||
// Skip internal Socket.IO events
|
// Skip internal Socket.IO events
|
||||||
if (eventName.startsWith('connect') || eventName.startsWith('disconnect') ||
|
if (eventName.startsWith('connect') || eventName.startsWith('disconnect') ||
|
||||||
eventName.startsWith('reconnect') || eventName === 'error') {
|
eventName.startsWith('reconnect') || eventName === 'error') {
|
||||||
return;
|
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
|
// Convert Socket.IO event to WebSocket message format
|
||||||
const message: WebSocketMessage = {
|
const message: WebSocketMessage = {
|
||||||
type: eventName,
|
type: eventName,
|
||||||
@@ -264,11 +276,16 @@ export class WebSocketService {
|
|||||||
Object.assign(message, args[0]);
|
Object.assign(message, args[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use unified message processing check
|
||||||
|
if (!this.shouldProcessMessage(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.handleMessage(message);
|
this.handleMessage(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private isDuplicateMessage(type: string, data: any): boolean {
|
private isDuplicateMessage(type: string, data: unknown): boolean {
|
||||||
const lastMessage = this.lastMessages.get(type);
|
const lastMessage = this.lastMessages.get(type);
|
||||||
if (!lastMessage) return false;
|
if (!lastMessage) return false;
|
||||||
|
|
||||||
@@ -288,11 +305,6 @@ export class WebSocketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(message: WebSocketMessage): void {
|
private handleMessage(message: WebSocketMessage): void {
|
||||||
// Add deduplication check
|
|
||||||
if (this.isDuplicateMessage(message.type, message.data)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store message for deduplication
|
// Store message for deduplication
|
||||||
this.lastMessages.set(message.type, {
|
this.lastMessages.set(message.type, {
|
||||||
data: message.data,
|
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()) {
|
if (!this.isConnected()) {
|
||||||
console.warn('Cannot send message: Socket.IO not connected');
|
console.warn('Cannot send message: Socket.IO not connected');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// For Socket.IO, we emit events based on message type
|
||||||
if (data.type) {
|
const messageData = data as { type?: string; data?: unknown };
|
||||||
this.socket!.emit(data.type, data.data || data);
|
if (messageData.type) {
|
||||||
|
this.socket!.emit(messageData.type, messageData.data || data);
|
||||||
} else {
|
} else {
|
||||||
// Default message event
|
// Default message event
|
||||||
this.socket!.emit('message', data);
|
this.socket!.emit('message', data);
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return operationId || true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
return false;
|
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
|
* Wait for connection to be established
|
||||||
*/
|
*/
|
||||||
@@ -462,6 +615,38 @@ export class WebSocketService {
|
|||||||
this.deduplicationWindow = windowMs;
|
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 {
|
disconnect(): void {
|
||||||
this.setState(WebSocketState.DISCONNECTED);
|
this.setState(WebSocketState.DISCONNECTED);
|
||||||
|
|
||||||
@@ -478,6 +663,13 @@ export class WebSocketService {
|
|||||||
this.connectionResolver = null;
|
this.connectionResolver = null;
|
||||||
this.connectionRejector = null;
|
this.connectionRejector = null;
|
||||||
this.lastMessages.clear(); // Clear deduplication cache
|
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);
|
return new WebSocketService(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instances for different features
|
// Create SEPARATE WebSocket instances for different features
|
||||||
export const knowledgeSocketIO = new WebSocketService();
|
// 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 taskUpdateSocketIO = new WebSocketService();
|
maxReconnectAttempts: 5,
|
||||||
export const projectListSocketIO = new WebSocketService();
|
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';
|
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
|
||||||
// Assignee type - simplified to predefined options
|
// Assignee type - flexible string to support MCP subagents
|
||||||
export type Assignee = 'User' | 'Archon' | 'AI IDE Agent';
|
export type Assignee = string;
|
||||||
|
|
||||||
// Base Project interface (matches database schema)
|
// Base Project interface (matches database schema)
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
prd?: Record<string, any>; // JSONB field
|
prd?: Record<string, any>; // JSONB field
|
||||||
docs?: any[]; // JSONB field
|
docs?: import('./jsonb').ProjectDocument[]; // Typed JSONB field
|
||||||
features?: any[]; // JSONB field
|
features?: import('./jsonb').ProjectFeature[]; // Typed JSONB field
|
||||||
data?: any[]; // JSONB field
|
data?: import('./jsonb').ProjectData[]; // Typed JSONB field
|
||||||
github_repo?: string;
|
github_repo?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -59,8 +59,8 @@ export interface Task {
|
|||||||
assignee: Assignee; // Now a database column with enum constraint
|
assignee: Assignee; // Now a database column with enum constraint
|
||||||
task_order: number; // New database column for priority ordering
|
task_order: number; // New database column for priority ordering
|
||||||
feature?: string; // New database column for feature name
|
feature?: string; // New database column for feature name
|
||||||
sources?: any[]; // JSONB field
|
sources?: import('./jsonb').TaskSource[]; // Typed JSONB field
|
||||||
code_examples?: any[]; // JSONB field
|
code_examples?: import('./jsonb').TaskCodeExample[]; // Typed JSONB field
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@ export interface CreateProjectRequest {
|
|||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
// Note: PRD data should be stored as a document in the docs array with document_type="prd"
|
// 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
|
// not as a direct 'prd' field since this column doesn't exist in the database
|
||||||
docs?: any[];
|
docs?: import('./jsonb').ProjectDocument[];
|
||||||
features?: any[];
|
features?: import('./jsonb').ProjectFeature[];
|
||||||
data?: any[];
|
data?: import('./jsonb').ProjectData[];
|
||||||
technical_sources?: string[];
|
technical_sources?: string[];
|
||||||
business_sources?: string[];
|
business_sources?: string[];
|
||||||
}
|
}
|
||||||
@@ -98,9 +98,9 @@ export interface UpdateProjectRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
github_repo?: string;
|
github_repo?: string;
|
||||||
prd?: Record<string, any>;
|
prd?: Record<string, any>;
|
||||||
docs?: any[];
|
docs?: import('./jsonb').ProjectDocument[];
|
||||||
features?: any[];
|
features?: import('./jsonb').ProjectFeature[];
|
||||||
data?: any[];
|
data?: import('./jsonb').ProjectData[];
|
||||||
technical_sources?: string[];
|
technical_sources?: string[];
|
||||||
business_sources?: string[];
|
business_sources?: string[];
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
@@ -117,8 +117,8 @@ export interface CreateTaskRequest {
|
|||||||
feature?: string;
|
feature?: string;
|
||||||
featureColor?: string;
|
featureColor?: string;
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
sources?: any[];
|
sources?: import('./jsonb').TaskSource[];
|
||||||
code_examples?: any[];
|
code_examples?: import('./jsonb').TaskCodeExample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update task request
|
// Update task request
|
||||||
@@ -131,8 +131,8 @@ export interface UpdateTaskRequest {
|
|||||||
feature?: string;
|
feature?: string;
|
||||||
featureColor?: string;
|
featureColor?: string;
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
sources?: any[];
|
sources?: import('./jsonb').TaskSource[];
|
||||||
code_examples?: any[];
|
code_examples?: import('./jsonb').TaskCodeExample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP tool response types
|
// 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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Import Socket.IO instance
|
# Import Socket.IO instance
|
||||||
from ..socketio_app import get_socketio_instance
|
from ..socketio_app import sio
|
||||||
|
|
||||||
sio = get_socketio_instance()
|
|
||||||
|
|
||||||
# Create router
|
# Create router
|
||||||
router = APIRouter(prefix="/api/agent-chat", tags=["agent-chat"])
|
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
|
# Get logger for this module
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
from ..socketio_app import get_socketio_instance
|
from ..socketio_app import sio
|
||||||
from .socketio_handlers import (
|
from .socketio_handlers import (
|
||||||
complete_crawl_progress,
|
complete_crawl_progress,
|
||||||
error_crawl_progress,
|
error_crawl_progress,
|
||||||
@@ -46,8 +46,6 @@ from .socketio_handlers import (
|
|||||||
# Create router
|
# Create router
|
||||||
router = APIRouter(prefix="/api", tags=["knowledge"])
|
router = APIRouter(prefix="/api", tags=["knowledge"])
|
||||||
|
|
||||||
# Get Socket.IO instance
|
|
||||||
sio = get_socketio_instance()
|
|
||||||
|
|
||||||
# Create a semaphore to limit concurrent crawls
|
# Create a semaphore to limit concurrent crawls
|
||||||
# This prevents the server from becoming unresponsive during heavy crawling
|
# This prevents the server from becoming unresponsive during heavy crawling
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ No other modules should import from this file.
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from ..config.logfire_config import get_logger
|
from ..config.logfire_config import get_logger
|
||||||
from ..socketio_app import get_socketio_instance
|
from ..socketio_app import sio
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Get Socket.IO instance
|
|
||||||
sio = get_socketio_instance()
|
|
||||||
|
|
||||||
|
|
||||||
# Core broadcast functions
|
# 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.background_task_manager import get_task_manager
|
||||||
from ..services.projects.project_service import ProjectService
|
from ..services.projects.project_service import ProjectService
|
||||||
from ..services.projects.source_linking_service import SourceLinkingService
|
from ..services.projects.source_linking_service import SourceLinkingService
|
||||||
from ..socketio_app import get_socketio_instance
|
from ..socketio_app import sio
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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
|
# Rate limiting for Socket.IO broadcasts
|
||||||
_last_broadcast_times: dict[str, float] = {}
|
_last_broadcast_times: dict[str, float] = {}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class KnowledgeItemService:
|
|||||||
code_example_counts[source_id] = 0
|
code_example_counts[source_id] = 0
|
||||||
chunk_counts[source_id] = 0 # Default to 0 to avoid timeout
|
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
|
# Transform sources to items with batched data
|
||||||
items = []
|
items = []
|
||||||
@@ -138,16 +138,18 @@ class KnowledgeItemService:
|
|||||||
|
|
||||||
# Use batched data instead of individual queries
|
# Use batched data instead of individual queries
|
||||||
first_page_url = first_urls.get(source_id, f"source://{source_id}")
|
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)
|
code_examples_count = code_example_counts.get(source_id, 0)
|
||||||
chunks_count = chunk_counts.get(source_id, 0)
|
chunks_count = chunk_counts.get(source_id, 0)
|
||||||
|
|
||||||
# Determine source type
|
# 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 = {
|
item = {
|
||||||
"id": source_id,
|
"id": source_id,
|
||||||
"title": source.get("title", source.get("summary", "Untitled")),
|
"title": source.get("title", source.get("summary", "Untitled")),
|
||||||
"url": first_page_url,
|
"url": original_url,
|
||||||
"source_id": source_id,
|
"source_id": source_id,
|
||||||
"code_examples": [{"count": code_examples_count}]
|
"code_examples": [{"count": code_examples_count}]
|
||||||
if code_examples_count > 0
|
if code_examples_count > 0
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ...config.logfire_config import get_logger
|
from ...config.logfire_config import get_logger
|
||||||
from ...socketio_app import get_socketio_instance
|
from ...socketio_app import sio
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Get Socket.IO instance
|
|
||||||
sio = get_socketio_instance()
|
|
||||||
logger.info(f"🔗 [PROGRESS] Socket.IO instance ID: {id(sio)}")
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressService:
|
class ProgressService:
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
# Import Socket.IO instance directly to avoid circular imports
|
# Import Socket.IO instance directly to avoid circular imports
|
||||||
try:
|
try:
|
||||||
from ...socketio_app import get_socketio_instance
|
from ...socketio_app import sio as _sio
|
||||||
|
|
||||||
_sio = get_socketio_instance()
|
|
||||||
_broadcast_available = True
|
_broadcast_available = True
|
||||||
logger.info("✅ Socket.IO broadcasting is AVAILABLE - real-time updates enabled")
|
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
|
# Prepare batch data - only for successful embeddings
|
||||||
batch_data = []
|
batch_data = []
|
||||||
|
used_indices = set() # Track which indices have been mapped to prevent duplicates
|
||||||
|
|
||||||
for j, (embedding, text) in enumerate(
|
for j, (embedding, text) in enumerate(
|
||||||
zip(valid_embeddings, successful_texts, strict=False)
|
zip(valid_embeddings, successful_texts, strict=False)
|
||||||
):
|
):
|
||||||
# Find the original index
|
# Find the original index (skip already used indices)
|
||||||
orig_idx = None
|
orig_idx = None
|
||||||
for k, orig_text in enumerate(batch_texts):
|
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
|
orig_idx = k
|
||||||
|
used_indices.add(k) # Mark this index as used
|
||||||
break
|
break
|
||||||
|
|
||||||
if orig_idx is None:
|
if orig_idx is None:
|
||||||
|
|||||||
@@ -252,20 +252,23 @@ async def add_documents_to_supabase(
|
|||||||
search_logger.warning(
|
search_logger.warning(
|
||||||
f"Skipping batch {batch_num} - no successful embeddings created"
|
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
|
continue
|
||||||
|
|
||||||
# Prepare batch data - only for successful embeddings
|
# Prepare batch data - only for successful embeddings
|
||||||
batch_data = []
|
batch_data = []
|
||||||
|
used_indices = set() # Track which indices have been mapped to prevent duplicates
|
||||||
|
|
||||||
# Map successful texts back to their original indices
|
# Map successful texts back to their original indices
|
||||||
for j, (embedding, text) in enumerate(
|
for j, (embedding, text) in enumerate(
|
||||||
zip(batch_embeddings, successful_texts, strict=False)
|
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
|
orig_idx = None
|
||||||
for idx, orig_text in enumerate(contextual_contents):
|
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
|
orig_idx = idx
|
||||||
|
used_indices.add(idx) # Mark this index as used
|
||||||
break
|
break
|
||||||
|
|
||||||
if orig_idx is None:
|
if orig_idx is None:
|
||||||
@@ -356,6 +359,9 @@ async def add_documents_to_supabase(
|
|||||||
search_logger.info(
|
search_logger.info(
|
||||||
f"Individual inserts: {successful_inserts}/{len(batch_data)} successful"
|
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
|
# Minimal delay between batches to prevent overwhelming
|
||||||
if i + batch_size < len(contents):
|
if i + batch_size < len(contents):
|
||||||
|
|||||||
@@ -93,16 +93,17 @@ class RateLimiter:
|
|||||||
self._clean_old_entries(now)
|
self._clean_old_entries(now)
|
||||||
|
|
||||||
# Check if we can make the request
|
# 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)
|
wait_time = self._calculate_wait_time(estimated_tokens)
|
||||||
if wait_time > 0:
|
if wait_time > 0:
|
||||||
logfire_logger.info(
|
logfire_logger.info(
|
||||||
f"Rate limiting: waiting {wait_time:.1f}s",
|
f"Rate limiting: waiting {wait_time:.1f}s (tokens={estimated_tokens}, current_usage={self._get_current_usage()})"
|
||||||
tokens=estimated_tokens,
|
|
||||||
current_usage=self._get_current_usage(),
|
|
||||||
)
|
)
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
return await self.acquire(estimated_tokens)
|
# Clean old entries after waiting
|
||||||
|
now = time.time()
|
||||||
|
self._clean_old_entries(now)
|
||||||
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Record the request
|
# Record the request
|
||||||
@@ -198,17 +199,13 @@ class MemoryAdaptiveDispatcher:
|
|||||||
# Reduce workers when memory is high
|
# Reduce workers when memory is high
|
||||||
workers = max(1, base // 2)
|
workers = max(1, base // 2)
|
||||||
logfire_logger.warning(
|
logfire_logger.warning(
|
||||||
"High memory usage detected, reducing workers",
|
f"High memory usage detected, reducing workers (memory_percent={metrics.memory_percent}, workers={workers})"
|
||||||
memory_percent=metrics.memory_percent,
|
|
||||||
workers=workers,
|
|
||||||
)
|
)
|
||||||
elif metrics.cpu_percent > self.config.cpu_threshold * 100:
|
elif metrics.cpu_percent > self.config.cpu_threshold * 100:
|
||||||
# Reduce workers when CPU is high
|
# Reduce workers when CPU is high
|
||||||
workers = max(1, base // 2)
|
workers = max(1, base // 2)
|
||||||
logfire_logger.warning(
|
logfire_logger.warning(
|
||||||
"High CPU usage detected, reducing workers",
|
f"High CPU usage detected, reducing workers (cpu_percent={metrics.cpu_percent}, workers={workers})"
|
||||||
cpu_percent=metrics.cpu_percent,
|
|
||||||
workers=workers,
|
|
||||||
)
|
)
|
||||||
elif metrics.memory_percent < 50 and metrics.cpu_percent < 50:
|
elif metrics.memory_percent < 50 and metrics.cpu_percent < 50:
|
||||||
# Increase workers when resources are available
|
# Increase workers when resources are available
|
||||||
@@ -238,12 +235,7 @@ class MemoryAdaptiveDispatcher:
|
|||||||
semaphore = asyncio.Semaphore(optimal_workers)
|
semaphore = asyncio.Semaphore(optimal_workers)
|
||||||
|
|
||||||
logfire_logger.info(
|
logfire_logger.info(
|
||||||
"Starting adaptive processing",
|
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})"
|
||||||
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
|
# Track active workers
|
||||||
@@ -318,7 +310,7 @@ class MemoryAdaptiveDispatcher:
|
|||||||
del active_workers[worker_id]
|
del active_workers[worker_id]
|
||||||
|
|
||||||
logfire_logger.error(
|
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
|
return None
|
||||||
|
|
||||||
@@ -333,11 +325,7 @@ class MemoryAdaptiveDispatcher:
|
|||||||
|
|
||||||
success_rate = len(successful_results) / len(items) * 100
|
success_rate = len(successful_results) / len(items) * 100
|
||||||
logfire_logger.info(
|
logfire_logger.info(
|
||||||
"Adaptive processing completed",
|
f"Adaptive processing completed (total_items={len(items)}, successful={len(successful_results)}, success_rate={success_rate:.1f}%, workers_used={optimal_workers})"
|
||||||
total_items=len(items),
|
|
||||||
successful=len(successful_results),
|
|
||||||
success_rate=f"{success_rate:.1f}%",
|
|
||||||
workers_used=optimal_workers,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return successful_results
|
return successful_results
|
||||||
@@ -355,7 +343,7 @@ class WebSocketSafeProcessor:
|
|||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
self.active_connections.append(websocket)
|
self.active_connections.append(websocket)
|
||||||
logfire_logger.info(
|
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):
|
def disconnect(self, websocket: WebSocket):
|
||||||
@@ -363,7 +351,7 @@ class WebSocketSafeProcessor:
|
|||||||
if websocket in self.active_connections:
|
if websocket in self.active_connections:
|
||||||
self.active_connections.remove(websocket)
|
self.active_connections.remove(websocket)
|
||||||
logfire_logger.info(
|
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]):
|
async def broadcast_progress(self, message: dict[str, Any]):
|
||||||
@@ -474,7 +462,7 @@ class ThreadingService:
|
|||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._health_check_task = asyncio.create_task(self._health_check_loop())
|
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):
|
async def stop(self):
|
||||||
"""Stop the threading service"""
|
"""Stop the threading service"""
|
||||||
@@ -510,7 +498,7 @@ class ThreadingService:
|
|||||||
finally:
|
finally:
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
logfire_logger.debug(
|
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:
|
async def run_cpu_intensive(self, func: Callable, *args, **kwargs) -> Any:
|
||||||
@@ -562,37 +550,30 @@ class ThreadingService:
|
|||||||
|
|
||||||
# Log system metrics
|
# Log system metrics
|
||||||
logfire_logger.info(
|
logfire_logger.info(
|
||||||
"System health check",
|
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)})"
|
||||||
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
|
# Alert on critical thresholds
|
||||||
if metrics.memory_percent > 90:
|
if metrics.memory_percent > 90:
|
||||||
logfire_logger.warning(
|
logfire_logger.warning(
|
||||||
"Critical memory usage", memory_percent=metrics.memory_percent
|
f"Critical memory usage (memory_percent={metrics.memory_percent})"
|
||||||
)
|
)
|
||||||
# Force garbage collection
|
# Force garbage collection
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
if metrics.cpu_percent > 95:
|
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)
|
# Check for memory leaks (too many threads)
|
||||||
if metrics.active_threads > self.config.max_workers * 3:
|
if metrics.active_threads > self.config.max_workers * 3:
|
||||||
logfire_logger.warning(
|
logfire_logger.warning(
|
||||||
"High thread count detected",
|
f"High thread count detected (active_threads={metrics.active_threads}, max_expected={self.config.max_workers * 3})"
|
||||||
active_threads=metrics.active_threads,
|
|
||||||
max_expected=self.config.max_workers * 3,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.sleep(self.config.health_check_interval)
|
await asyncio.sleep(self.config.health_check_interval)
|
||||||
|
|
||||||
except Exception as e:
|
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)
|
await asyncio.sleep(self.config.health_check_interval)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ sio = socketio.AsyncServer(
|
|||||||
# Global Socket.IO instance for use across modules
|
# Global Socket.IO instance for use across modules
|
||||||
_socketio_instance: socketio.AsyncServer | None = None
|
_socketio_instance: socketio.AsyncServer | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_socketio_instance() -> socketio.AsyncServer:
|
def get_socketio_instance() -> socketio.AsyncServer:
|
||||||
"""Get the global Socket.IO server instance."""
|
"""Get the global Socket.IO server instance."""
|
||||||
global _socketio_instance
|
global _socketio_instance
|
||||||
|
|||||||
Reference in New Issue
Block a user