- 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:
sean-eskerium
2025-08-20 02:28:02 -04:00
parent cd22089b87
commit 1b5196d70f
44 changed files with 3490 additions and 331 deletions

View File

@@ -12,6 +12,7 @@
"@milkdown/kit": "^7.5.0",
"@milkdown/plugin-history": "^7.5.0",
"@milkdown/preset-commonmark": "^7.5.0",
"@types/uuid": "^10.0.0",
"@xyflow/react": "^12.3.0",
"clsx": "latest",
"date-fns": "^4.1.0",
@@ -26,6 +27,7 @@
"react-router-dom": "^6.26.2",
"socket.io-client": "^4.8.1",
"tailwind-merge": "latest",
"uuid": "^11.1.0",
"zod": "^3.25.46"
},
"devDependencies": {
@@ -2977,6 +2979,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
@@ -10025,6 +10033,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -22,6 +22,7 @@
"@milkdown/kit": "^7.5.0",
"@milkdown/plugin-history": "^7.5.0",
"@milkdown/preset-commonmark": "^7.5.0",
"@types/uuid": "^10.0.0",
"@xyflow/react": "^12.3.0",
"clsx": "latest",
"date-fns": "^4.1.0",
@@ -36,6 +37,7 @@
"react-router-dom": "^6.26.2",
"socket.io-client": "^4.8.1",
"tailwind-merge": "latest",
"uuid": "^11.1.0",
"zod": "^3.25.46"
},
"devDependencies": {

View 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;
};
}

View 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
};
}

View File

@@ -149,7 +149,7 @@ export const KnowledgeItemCard = ({
const [showPageTooltip, setShowPageTooltip] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [loadedCodeExamples, setLoadedCodeExamples] = useState<any[] | null>(null);
const [loadedCodeExamples, setLoadedCodeExamples] = useState<Array<{id: string; summary: string; code: string; language?: string}> | null>(null);
const [isLoadingCodeExamples, setIsLoadingCodeExamples] = useState(false);
const statusColorMap = {

View File

@@ -601,21 +601,18 @@ export const DocsTab = ({
try {
setIsSaving(true);
// Create a new document with a unique ID
const newDocument: ProjectDoc = {
id: `doc-${Date.now()}`,
// Create document via backend API
const newDocument = await projectService.createDocument(project.id, {
title: template.name,
content: template.content,
document_type: template.document_type,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
document_type: template.document_type
});
// Add to documents list
setDocuments(prev => [...prev, newDocument]);
setSelectedDocument(newDocument);
console.log('Document created successfully:', newDocument);
console.log('Document created successfully via API:', newDocument);
showToast('Document created successfully', 'success');
setShowTemplateModal(false);
} catch (error) {
@@ -636,18 +633,19 @@ export const DocsTab = ({
try {
setIsSaving(true);
// Update the document in local state
const updatedDocument = {
...selectedDocument,
updated_at: new Date().toISOString()
};
// Update the document via backend API
const updatedDocument = await projectService.updateDocument(project.id, selectedDocument.id, {
...selectedDocument,
updated_at: new Date().toISOString()
});
// Update local state with the response from backend
setDocuments(prev => prev.map(doc =>
doc.id === selectedDocument.id ? updatedDocument : doc
));
setSelectedDocument(updatedDocument);
console.log('Document saved successfully:', updatedDocument);
console.log('Document saved successfully via API:', updatedDocument);
showToast('Document saved successfully', 'success');
setIsEditing(false);
} catch (error) {
@@ -938,6 +936,8 @@ export const DocsTab = ({
isActive={selectedDocument?.id === doc.id}
onSelect={setSelectedDocument}
onDelete={async (docId) => {
if (!project?.id) return;
try {
// Call API to delete from database first
await projectService.deleteDocument(project.id, docId);
@@ -981,22 +981,24 @@ export const DocsTab = ({
document={selectedDocument}
isDarkMode={isDarkMode}
onSave={async (updatedDocument) => {
if (!project?.id) return;
try {
setIsSaving(true);
// Update document with timestamp
const docWithTimestamp = {
// Update document via backend API
const savedDocument = await projectService.updateDocument(project.id, updatedDocument.id, {
...updatedDocument,
updated_at: new Date().toISOString()
};
});
// Update local state
setSelectedDocument(docWithTimestamp);
// Update local state with the response from backend
setSelectedDocument(savedDocument);
setDocuments(prev => prev.map(doc =>
doc.id === updatedDocument.id ? docWithTimestamp : doc
doc.id === updatedDocument.id ? savedDocument : doc
));
console.log('Document saved via MilkdownEditor');
console.log('Document saved via MilkdownEditor API:', savedDocument);
showToast('Document saved successfully', 'success');
} catch (error) {
console.error('Failed to save document:', error);
@@ -1190,7 +1192,7 @@ const TemplateModal: React.FC<{
const KnowledgeSection: React.FC<{
title: string;
color: 'blue' | 'purple' | 'pink' | 'orange';
sources: any[];
sources: Array<{id: string; title: string; type: string; lastUpdated: string} | undefined>;
onAddClick: () => void;
}> = ({
title,
@@ -1273,7 +1275,7 @@ const KnowledgeSection: React.FC<{
const SourceSelectionModal: React.FC<{
title: string;
sources: any[];
sources: Array<{id: string; title: string; type: string; lastUpdated: string}>;
selectedSources: string[];
onToggleSource: (id: string) => void;
onSave: () => void;

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Rocket, Code, Briefcase, Users, FileText, X, Plus, Clipboard } from 'lucide-react';
import { useToast } from '../../contexts/ToastContext';
import { copyToClipboard } from '../../utils/clipboard';
export interface ProjectDoc {
id: string;
@@ -49,18 +50,22 @@ export const DocumentCard: React.FC<DocumentCardProps> = ({
}
};
const handleCopyId = (e: React.MouseEvent) => {
const handleCopyId = async (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(document.id);
showToast('Document ID copied to clipboard', 'success');
// Visual feedback
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
const success = await copyToClipboard(document.id);
if (success) {
showToast('Document ID copied to clipboard', 'success');
// Visual feedback
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
} else {
showToast('Failed to copy Document ID', 'error');
}
};
return (

View File

@@ -3,6 +3,8 @@ import { useDrag, useDrop } from 'react-dnd';
import { Edit, Trash2, RefreshCw, Tag, User, Bot, Clipboard } from 'lucide-react';
import { Task } from './TaskTableView';
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
import { copyToClipboard } from '../../utils/clipboard';
import { useToast } from '../../contexts/ToastContext';
export interface DraggableTaskCardProps {
task: Task;
@@ -27,6 +29,7 @@ export const DraggableTaskCard = ({
hoveredTaskId,
onTaskHover,
}: DraggableTaskCardProps) => {
const { showToast } = useToast();
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.TASK,
@@ -188,16 +191,21 @@ export const DraggableTaskCard = ({
<span className="text-gray-600 dark:text-gray-400 text-xs">{task.assignee?.name || 'User'}</span>
</div>
<button
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
// Optional: Add a small toast or visual feedback here
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<span class="text-green-500">Copied!</span>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
const success = await copyToClipboard(task.id);
if (success) {
showToast('Task ID copied to clipboard', 'success');
// Visual feedback
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<span class="text-green-500">Copied!</span>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
} else {
showToast('Failed to copy Task ID', 'error');
}
}}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
title="Copy Task ID to clipboard"

View File

@@ -8,7 +8,7 @@ import type { Task } from './TaskTableView';
interface EditTaskModalProps {
isModalOpen: boolean;
editingTask: Task | null;
projectFeatures: any[];
projectFeatures: import('../types/jsonb').ProjectFeature[];
isLoadingFeatures: boolean;
isSavingTask: boolean;
onClose: () => void;

View File

@@ -149,7 +149,7 @@ interface FeaturesTabProps {
project?: {
id: string;
title: string;
features?: any[];
features?: import('../types/jsonb').ProjectFeature[];
} | null;
}

View File

@@ -92,7 +92,7 @@ DebouncedInput.displayName = 'DebouncedInput';
interface FeatureInputProps {
value: string;
onChange: (value: string) => void;
projectFeatures: any[];
projectFeatures: import('../types/jsonb').ProjectFeature[];
isLoadingFeatures: boolean;
placeholder?: string;
className?: string;

View File

@@ -6,6 +6,7 @@ import { DeleteConfirmModal } from '../../pages/ProjectPage';
import { projectService } from '../../services/projectService';
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
import { DraggableTaskCard } from './DraggableTaskCard';
import { copyToClipboard } from '../../utils/clipboard';
export interface Task {
id: string;
@@ -13,7 +14,7 @@ export interface Task {
description: string;
status: 'backlog' | 'in-progress' | 'review' | 'complete';
assignee: {
name: 'User' | 'Archon' | 'AI IDE Agent';
name: string; // Allow any assignee name for MCP subagents
avatar: string;
};
feature: string;
@@ -31,7 +32,7 @@ interface TaskTableViewProps {
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
}
const getAssigneeGlassStyle = (assigneeName: 'User' | 'Archon' | 'AI IDE Agent') => {
const getAssigneeGlassStyle = (assigneeName: string) => {
switch (assigneeName) {
case 'User':
return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-blue-400 dark:border-blue-500'; // blue glass
@@ -208,6 +209,7 @@ const DraggableTaskRow = ({
}: DraggableTaskRowProps) => {
const [editingField, setEditingField] = useState<string | null>(null);
const [isHovering, setIsHovering] = useState(false);
const { showToast } = useToast();
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.TASK,
@@ -252,7 +254,7 @@ const DraggableTaskRow = ({
} else if (field === 'status') {
updates.status = value as Task['status'];
} else if (field === 'assignee') {
updates.assignee = { name: value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' };
updates.assignee = { name: value || 'AI IDE Agent', avatar: '' };
} else if (field === 'feature') {
updates.feature = value;
}
@@ -336,32 +338,14 @@ const DraggableTaskRow = ({
</div>
</td>
<td className="p-3">
<div className="flex items-center justify-center">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full border-2 transition-all duration-300 cursor-pointer hover:scale-110 ${getAssigneeGlassStyle(task.assignee?.name || 'User')} ${getAssigneeGlow(task.assignee?.name || 'User')}`}
onClick={() => setEditingField('assignee')}
title={`Assignee: ${task.assignee?.name || 'User'}`}
>
{getAssigneeIcon(task.assignee?.name || 'User')}
</div>
{editingField === 'assignee' && (
<div className="absolute z-50 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-2">
<select
value={task.assignee?.name || 'User'}
onChange={(e) => {
handleUpdateField('assignee', e.target.value);
setEditingField(null);
}}
className="bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-cyan-500"
autoFocus
>
<option value="User">User</option>
<option value="Archon">Archon</option>
<option value="AI IDE Agent">AI IDE Agent</option>
</select>
</div>
)}
</div>
<EditableCell
value={task.assignee?.name || 'AI IDE Agent'}
onSave={(value) => handleUpdateField('assignee', value || 'AI IDE Agent')}
isEditing={editingField === 'assignee'}
onEdit={() => setEditingField('assignee')}
onCancel={() => setEditingField(null)}
placeholder="AI IDE Agent"
/>
</td>
<td className="p-3">
<div className="flex justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -385,16 +369,21 @@ const DraggableTaskRow = ({
</button>
{/* Copy Task ID Button - Matching Board View */}
<button
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
// Visual feedback like in board view
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
const success = await copyToClipboard(task.id);
if (success) {
showToast('Task ID copied to clipboard', 'success');
// Visual feedback like in board view
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
} else {
showToast('Failed to copy Task ID', 'error');
}
}}
className="p-1.5 rounded-full bg-gray-500/20 text-gray-500 hover:bg-gray-500/30 hover:shadow-[0_0_10px_rgba(107,114,128,0.3)] transition-all duration-300"
title="Copy Task ID to clipboard"
@@ -524,18 +513,17 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
/>
</td>
<td className="p-3">
<select
<input
type="text"
value={newTask.assignee.name}
onChange={(e) => setNewTask(prev => ({
...prev,
assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' }
assignee: { name: e.target.value || 'AI IDE Agent', avatar: '' }
}))}
onKeyPress={handleKeyPress}
placeholder="AI IDE Agent"
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]"
>
<option value="AI IDE Agent">AI IDE Agent</option>
<option value="User">User</option>
<option value="Archon">Archon</option>
</select>
/>
</td>
<td className="p-3">
<div className="flex justify-center">

View File

@@ -4,9 +4,11 @@ import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Toggle } from '../ui/Toggle';
import { projectService } from '../../services/projectService';
import { getGlobalOperationTracker } from '../../utils/operationTracker';
import { useTaskSocket } from '../../hooks/useTaskSocket';
import type { CreateTaskRequest, UpdateTaskRequest, DatabaseTaskStatus } from '../../types/project';
import { WebSocketState } from '../../services/socketIOService';
import { TaskTableView, Task } from './TaskTableView';
import { TaskBoardView } from './TaskBoardView';
import { EditTaskModal } from './EditTaskModal';
@@ -65,11 +67,14 @@ export const TasksTab = ({
const [tasks, setTasks] = useState<Task[]>([]);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [projectFeatures, setProjectFeatures] = useState<any[]>([]);
const [projectFeatures, setProjectFeatures] = useState<import('../types/jsonb').ProjectFeature[]>([]);
const [isLoadingFeatures, setIsLoadingFeatures] = useState(false);
const [isSavingTask, setIsSavingTask] = useState<boolean>(false);
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
// Track recently deleted tasks to prevent race conditions
const [recentlyDeletedIds, setRecentlyDeletedIds] = useState<Set<string>>(new Set());
// Initialize tasks
useEffect(() => {
setTasks(initialTasks);
@@ -85,6 +90,12 @@ export const TasksTab = ({
const updatedTask = message.data || message;
const mappedTask = mapDatabaseTaskToUITask(updatedTask);
// Skip updates for recently deleted tasks (race condition prevention)
if (recentlyDeletedIds.has(updatedTask.id)) {
console.log('[Socket] Ignoring update for recently deleted task:', updatedTask.id);
return;
}
// Skip updates while modal is open for the same task to prevent conflicts
if (isModalOpen && editingTask?.id === updatedTask.id) {
console.log('[Socket] Skipping update for task being edited:', updatedTask.id);
@@ -94,20 +105,15 @@ export const TasksTab = ({
setTasks(prev => {
// Use server timestamp for conflict resolution
const existingTask = prev.find(task => task.id === updatedTask.id);
if (existingTask) {
// Check if this is a more recent update
const serverTimestamp = message.server_timestamp || Date.now();
const lastUpdate = existingTask.lastUpdate || 0;
if (serverTimestamp <= lastUpdate) {
console.log('[Socket] Ignoring stale update for task:', updatedTask.id);
return prev;
}
// Skip if we already have this task (prevent duplicate additions)
if (!existingTask) {
console.log('[Socket] Task not found locally, adding:', updatedTask.id);
}
const updated = prev.map(task =>
task.id === updatedTask.id
? { ...mappedTask, lastUpdate: message.server_timestamp || Date.now() }
? { ...mappedTask }
: task
);
@@ -115,7 +121,7 @@ export const TasksTab = ({
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
}, [onTasksChange, isModalOpen, editingTask?.id]);
}, [onTasksChange, isModalOpen, editingTask?.id, recentlyDeletedIds]);
const handleTaskCreated = useCallback((message: any) => {
const newTask = message.data || message;
@@ -123,11 +129,27 @@ export const TasksTab = ({
const mappedTask = mapDatabaseTaskToUITask(newTask);
setTasks(prev => {
// Check if this is replacing a temporary task from optimistic update
const hasTempTask = prev.some(task => task.id.startsWith('temp-') && task.title === mappedTask.title);
if (hasTempTask) {
// Replace temporary task with real task
const updated = prev.map(task =>
task.id.startsWith('temp-') && task.title === mappedTask.title
? mappedTask
: task
);
setTimeout(() => onTasksChange(updated), 0);
console.log('Replaced temporary task with real task:', mappedTask.id);
return updated;
}
// Check if task already exists to prevent duplicates
if (prev.some(task => task.id === newTask.id)) {
console.log('Task already exists, skipping create');
return prev;
}
const updated = [...prev, mappedTask];
setTimeout(() => onTasksChange(updated), 0);
return updated;
@@ -137,6 +159,14 @@ export const TasksTab = ({
const handleTaskDeleted = useCallback((message: any) => {
const deletedTask = message.data || message;
console.log('🗑️ Real-time task deleted:', deletedTask);
// Remove from recently deleted cache when deletion is confirmed
setRecentlyDeletedIds(prev => {
const newSet = new Set(prev);
newSet.delete(deletedTask.id);
return newSet;
});
setTasks(prev => {
const updated = prev.filter(task => task.id !== deletedTask.id);
setTimeout(() => onTasksChange(updated), 0);
@@ -183,7 +213,7 @@ export const TasksTab = ({
onTasksReordered: handleTasksReordered,
onInitialTasks: handleInitialTasks,
onConnectionStateChange: (state) => {
setIsWebSocketConnected(state === 'connected');
setIsWebSocketConnected(state === WebSocketState.CONNECTED);
}
});
@@ -305,29 +335,53 @@ export const TasksTab = ({
};
};
// Improved debounced persistence with better coordination
// Batch reorder persistence for efficient updates
const debouncedPersistBatchReorder = useMemo(
() => debounce(async (tasksToUpdate: Task[]) => {
try {
console.log(`REORDER: Persisting batch update for ${tasksToUpdate.length} tasks`);
// Send batch update request to backend
// For now, update tasks individually (backend can be optimized later for batch endpoint)
const updatePromises = tasksToUpdate.map(task =>
projectService.updateTask(task.id, {
task_order: task.task_order
})
);
await Promise.all(updatePromises);
console.log('REORDER: Batch reorder persisted successfully');
} catch (error) {
console.error('REORDER: Failed to persist batch reorder:', error);
// Socket will handle state recovery
console.log('REORDER: Socket will handle state recovery');
}
}, 500), // Shorter delay for batch updates
[projectId]
);
// Single task persistence (still used for other operations)
const debouncedPersistSingleTask = useMemo(
() => debounce(async (task: Task) => {
try {
console.log('REORDER: Persisting position change for task:', task.title, 'new position:', task.task_order);
// Update only the moved task with server timestamp for conflict resolution
// Update only the moved task
await projectService.updateTask(task.id, {
task_order: task.task_order,
client_timestamp: Date.now()
task_order: task.task_order
});
console.log('REORDER: Single task position persisted successfully');
} catch (error) {
console.error('REORDER: Failed to persist task position:', error);
// Don't reload tasks immediately - let socket handle recovery
console.log('REORDER: Socket will handle state recovery');
}
}, 800), // Slightly reduced delay for better responsiveness
}, 800),
[projectId]
);
// Optimized task reordering without optimistic update conflicts
// Standard drag-and-drop reordering with sequential integers (like Jira/Trello/Linear)
const handleTaskReorder = useCallback((taskId: string, targetIndex: number, status: Task['status']) => {
console.log('REORDER: Moving task', taskId, 'to index', targetIndex, 'in status', status);
@@ -357,63 +411,37 @@ export const TasksTab = ({
return;
}
const movingTask = statusTasks[movingTaskIndex];
console.log('REORDER: Moving', movingTask.title, 'from', movingTaskIndex, 'to', targetIndex);
console.log('REORDER: Moving task from position', movingTaskIndex, 'to', targetIndex);
// Calculate new position using improved algorithm
let newPosition: number;
// Remove the task from its current position and insert at target position
const reorderedTasks = [...statusTasks];
const [movedTask] = reorderedTasks.splice(movingTaskIndex, 1);
reorderedTasks.splice(targetIndex, 0, movedTask);
if (targetIndex === 0) {
// Moving to first position
const firstTask = statusTasks[0];
newPosition = firstTask.task_order / 2;
} else if (targetIndex === statusTasks.length - 1) {
// Moving to last position
const lastTask = statusTasks[statusTasks.length - 1];
newPosition = lastTask.task_order + 1024;
} else {
// Moving between two items
let prevTask, nextTask;
if (targetIndex > movingTaskIndex) {
// Moving down
prevTask = statusTasks[targetIndex];
nextTask = statusTasks[targetIndex + 1];
} else {
// Moving up
prevTask = statusTasks[targetIndex - 1];
nextTask = statusTasks[targetIndex];
}
if (prevTask && nextTask) {
newPosition = (prevTask.task_order + nextTask.task_order) / 2;
} else if (prevTask) {
newPosition = prevTask.task_order + 1024;
} else if (nextTask) {
newPosition = nextTask.task_order / 2;
} else {
newPosition = 1024; // Fallback
}
}
// Assign sequential order numbers (1, 2, 3, etc.) to all tasks in this status
const updatedStatusTasks = reorderedTasks.map((task, index) => ({
...task,
task_order: index + 1,
lastUpdate: Date.now()
}));
console.log('REORDER: New position calculated:', newPosition);
console.log('REORDER: New order:', updatedStatusTasks.map(t => `${t.title}:${t.task_order}`));
// Create updated task with new position and timestamp
const updatedTask = {
...movingTask,
task_order: newPosition,
lastUpdate: Date.now() // Add timestamp for conflict resolution
};
// Immediate UI update without optimistic tracking interference
const allUpdatedTasks = otherTasks.concat(
statusTasks.map(task => task.id === taskId ? updatedTask : task)
);
// Update UI immediately with all reordered tasks
const allUpdatedTasks = [...otherTasks, ...updatedStatusTasks];
updateTasks(allUpdatedTasks);
// Persist to backend (single API call)
debouncedPersistSingleTask(updatedTask);
}, [tasks, updateTasks, debouncedPersistSingleTask]);
// Batch update to backend - only update tasks that changed position
const tasksToUpdate = updatedStatusTasks.filter((task, index) => {
const originalTask = statusTasks.find(t => t.id === task.id);
return originalTask && originalTask.task_order !== task.task_order;
});
console.log(`REORDER: Updating ${tasksToUpdate.length} tasks in backend`);
// Send batch update to backend (debounced)
debouncedPersistBatchReorder(tasksToUpdate);
}, [tasks, updateTasks, debouncedPersistBatchReorder]);
// Task move function (for board view)
const moveTask = async (taskId: string, newStatus: Task['status']) => {
@@ -433,8 +461,7 @@ export const TasksTab = ({
// Update the task with new status and order
await projectService.updateTask(taskId, {
status: mapUIStatusToDBStatus(newStatus),
task_order: newOrder,
client_timestamp: Date.now()
task_order: newOrder
});
console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`);
@@ -498,9 +525,7 @@ export const TasksTab = ({
const updateTaskInline = async (taskId: string, updates: Partial<Task>) => {
console.log(`[TasksTab] Inline update for task ${taskId} with updates:`, updates);
try {
const updateData: Partial<UpdateTaskRequest> = {
client_timestamp: Date.now()
};
const updateData: Partial<UpdateTaskRequest> = {};
if (updates.title !== undefined) updateData.title = updates.title;
if (updates.description !== undefined) updateData.description = updates.description;

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { X, Clock, RotateCcw, Eye, Calendar, User, FileText, Diff, GitBranch, Layers, Plus, Minus, AlertTriangle } from 'lucide-react';
import projectService from '../../services/projectService';
import { projectService } from '../../services/projectService';
import { Button } from '../ui/Button';
import { useToast } from '../../contexts/ToastContext';

View File

@@ -92,7 +92,7 @@ function formatSectionContent(value: any): string {
/**
* Formats array content as markdown list or nested structure
*/
function formatArrayContent(array: any[]): string {
function formatArrayContent(array: unknown[]): string {
if (array.length === 0) {
return '_No items_';
}

View File

@@ -60,7 +60,7 @@ export interface PRPPersona {
export interface PRPPhase {
duration?: string;
deliverables?: string[];
tasks?: any[];
tasks?: Array<{title: string; files: string[]; details: string}>;
[key: string]: any;
}

View File

@@ -9,6 +9,7 @@ import { DocsTab } from '../components/project-tasks/DocsTab';
import { TasksTab } from '../components/project-tasks/TasksTab';
import { Button } from '../components/ui/Button';
import { ChevronRight, ShoppingCart, Code, Briefcase, Layers, Plus, X, AlertCircle, Loader2, Heart, BarChart3, Trash2, Pin, ListTodo, Activity, CheckCircle2, Clipboard } from 'lucide-react';
import { copyToClipboard } from '../utils/clipboard';
// Import our service layer and types
import { projectService } from '../services/projectService';
@@ -844,17 +845,21 @@ export function ProjectPage({
{/* Copy Project ID Button */}
<button
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
navigator.clipboard.writeText(project.id);
showToast('Project ID copied to clipboard', 'success');
// Visual feedback
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<svg class="w-3 h-3 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>Copied!';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
const success = await copyToClipboard(project.id);
if (success) {
showToast('Project ID copied to clipboard', 'success');
// Visual feedback
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<svg class="w-3 h-3 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>Copied!';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
} else {
showToast('Failed to copy Project ID', 'error');
}
}}
className="flex-1 flex items-center justify-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors py-1"
title="Copy Project ID to clipboard"

View File

@@ -8,7 +8,6 @@ import {
Key,
Brain,
Code,
Activity,
FileCode,
Bug,
} from "lucide-react";
@@ -20,7 +19,6 @@ import { FeaturesSection } from "../components/settings/FeaturesSection";
import { APIKeysSection } from "../components/settings/APIKeysSection";
import { RAGSettings } from "../components/settings/RAGSettings";
import { CodeExtractionSettings } from "../components/settings/CodeExtractionSettings";
import { TestStatus } from "../components/settings/TestStatus";
import { IDEGlobalRules } from "../components/settings/IDEGlobalRules";
import { ButtonPlayground } from "../components/settings/ButtonPlayground";
import { CollapsibleSettingsCard } from "../components/ui/CollapsibleSettingsCard";
@@ -151,15 +149,31 @@ export const SettingsPage = () => {
</CollapsibleSettingsCard>
</motion.div>
)}
{/* Bug Report Section - Moved to left column */}
<motion.div variants={itemVariants}>
<CollapsibleSettingsCard
title="Test Status"
icon={Activity}
accentColor="cyan"
storageKey="test-status"
defaultExpanded={true}
title="Bug Reporting"
icon={Bug}
iconColor="text-red-500"
borderColor="border-red-200 dark:border-red-800"
defaultExpanded={false}
>
<TestStatus />
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Found a bug or issue? Report it to help improve Archon V2
Alpha.
</p>
<div className="flex justify-start">
<BugReportButton variant="secondary" size="md">
Report Bug
</BugReportButton>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p> Bug reports are sent directly to GitHub Issues</p>
<p> System context is automatically collected</p>
<p> Your privacy is protected - no personal data is sent</p>
</div>
</div>
</CollapsibleSettingsCard>
</motion.div>
</div>
@@ -205,34 +219,6 @@ export const SettingsPage = () => {
/>
</CollapsibleSettingsCard>
</motion.div>
{/* Bug Report Section */}
<motion.div variants={itemVariants}>
<CollapsibleSettingsCard
title="Bug Reporting"
icon={Bug}
iconColor="text-red-500"
borderColor="border-red-200 dark:border-red-800"
defaultExpanded={false}
>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Found a bug or issue? Report it to help improve Archon V2
Alpha.
</p>
<div className="flex justify-start">
<BugReportButton variant="secondary" size="md">
Report Bug
</BugReportButton>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p> Bug reports are sent directly to GitHub Issues</p>
<p> System context is automatically collected</p>
<p> Your privacy is protected - no personal data is sent</p>
</div>
</div>
</CollapsibleSettingsCard>
</motion.div>
</div>
</div>

View 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;
}

View File

@@ -36,7 +36,7 @@ export interface CrawlProgressData {
currentStep?: string;
logs?: string[];
log?: string;
workers?: WorkerProgress[] | any[]; // Updated to support new worker format
workers?: WorkerProgress[]; // Updated to support new worker format
error?: string;
completed?: boolean;
// Additional properties for document upload and crawling
@@ -50,6 +50,7 @@ export interface CrawlProgressData {
wordCount?: number;
duration?: string;
sourceId?: string;
codeExamplesCount?: number;
// Original crawl parameters for retry functionality
originalCrawlParams?: {
url: string;
@@ -98,7 +99,7 @@ interface StreamProgressOptions {
connectionTimeout?: number;
}
type ProgressCallback = (data: any) => void;
type ProgressCallback = (data: CrawlProgressData) => void;
class CrawlProgressService {
private wsService: WebSocketService = knowledgeSocketIO;
@@ -115,6 +116,9 @@ class CrawlProgressService {
options: StreamProgressOptions = {}
): Promise<void> {
console.log(`🚀 Starting Socket.IO progress stream for ${progressId}`);
// Store the active crawl progress ID in localStorage for reconnection
localStorage.setItem('activeCrawlProgressId', progressId);
try {
// Ensure we're connected to Socket.IO
@@ -141,7 +145,7 @@ class CrawlProgressService {
}, 5000); // 5 second timeout for acknowledgment
// Listen for subscription acknowledgment
const ackHandler = (message: any) => {
const ackHandler = (message: { data?: Record<string, unknown>; progress_id?: string; status?: string }) => {
const data = message.data || message;
console.log(`📨 Received acknowledgment:`, data);
if (data.progress_id === progressId && data.status === 'subscribed') {
@@ -156,7 +160,7 @@ class CrawlProgressService {
});
// Create a specific handler for this progressId
const progressHandler = (message: any) => {
const progressHandler = (message: { data?: CrawlProgressData; progressId?: string }) => {
console.log(`📨 [${progressId}] Raw message received:`, message);
const data = message.data || message;
console.log(`📨 [${progressId}] Extracted data:`, data);
@@ -185,6 +189,8 @@ class CrawlProgressService {
console.log(`✅ Crawl completed for ${progressId}`);
if (data.progressId === progressId) {
onMessage({ ...data, completed: true });
// Clear the stored progress ID when crawl completes
localStorage.removeItem('activeCrawlProgressId');
}
});
@@ -197,6 +203,8 @@ class CrawlProgressService {
error: message.data?.message || message.error || 'Unknown error',
percentage: 0
});
// Clear the stored progress ID on error
localStorage.removeItem('activeCrawlProgressId');
}
});
@@ -298,6 +306,12 @@ class CrawlProgressService {
// Remove from active subscriptions
this.activeSubscriptions.delete(progressId);
// Clear from localStorage if this is the active crawl
const storedId = localStorage.getItem('activeCrawlProgressId');
if (storedId === progressId) {
localStorage.removeItem('activeCrawlProgressId');
}
}
/**
@@ -378,8 +392,8 @@ class CrawlProgressService {
progressId: string,
callbacks: {
onMessage: ProgressCallback;
onStateChange?: (state: any) => void;
onError?: (error: any) => void;
onStateChange?: (state: string) => void;
onError?: (error: Error) => void;
},
options: StreamProgressOptions = {}
): Promise<void> {

View File

@@ -13,6 +13,7 @@
*/
import { io, Socket } from 'socket.io-client';
import { OperationTracker, OperationResult } from '../utils/operationTracker';
export enum WebSocketState {
CONNECTING = 'CONNECTING',
@@ -33,9 +34,9 @@ export interface WebSocketConfig {
export interface WebSocketMessage {
type: string;
data?: any;
data?: unknown;
timestamp?: string;
[key: string]: any;
[key: string]: unknown;
}
type MessageHandler = (message: WebSocketMessage) => void;
@@ -57,8 +58,12 @@ export class WebSocketService {
private _state: WebSocketState = WebSocketState.DISCONNECTED;
// Deduplication support
private lastMessages: Map<string, { data: any; timestamp: number }> = new Map();
private lastMessages: Map<string, { data: unknown; timestamp: number }> = new Map();
private deduplicationWindow = 100; // 100ms window
// Operation tracking support
private operationTracker: OperationTracker | null = null;
private operationHandlers: Map<string, (result: OperationResult) => void> = new Map();
constructor(config: WebSocketConfig = {}) {
this.config = {
@@ -215,9 +220,9 @@ export class WebSocketService {
this.socket.on('connect_error', (error: Error) => {
console.error('❌ Socket.IO connection error:', error);
console.error('❌ Error type:', (error as any).type);
console.error('❌ Error type:', (error as unknown as Record<string, unknown>).type);
console.error('❌ Error message:', error.message);
console.error('❌ Socket transport:', this.socket?.io?.engine?.transport?.name);
console.error('❌ Socket transport:', (this.socket as unknown as { io?: { engine?: { transport?: { name?: string } } } })?.io?.engine?.transport?.name);
this.notifyError(error);
// Reject connection promise if still pending
@@ -244,13 +249,20 @@ export class WebSocketService {
});
// Handle incoming messages
this.socket.onAny((eventName: string, ...args: any[]) => {
this.socket.onAny((eventName: string, ...args: unknown[]) => {
// Skip internal Socket.IO events
if (eventName.startsWith('connect') || eventName.startsWith('disconnect') ||
eventName.startsWith('reconnect') || eventName === 'error') {
return;
}
// Check for operation responses
if (eventName === 'operation_response' && args[0]) {
const response = args[0] as { operationId: string; success: boolean; data?: unknown; error?: string };
this.handleOperationResponse(response);
return;
}
// Convert Socket.IO event to WebSocket message format
const message: WebSocketMessage = {
type: eventName,
@@ -264,11 +276,16 @@ export class WebSocketService {
Object.assign(message, args[0]);
}
// Use unified message processing check
if (!this.shouldProcessMessage(message)) {
return;
}
this.handleMessage(message);
});
}
private isDuplicateMessage(type: string, data: any): boolean {
private isDuplicateMessage(type: string, data: unknown): boolean {
const lastMessage = this.lastMessages.get(type);
if (!lastMessage) return false;
@@ -288,11 +305,6 @@ export class WebSocketService {
}
private handleMessage(message: WebSocketMessage): void {
// Add deduplication check
if (this.isDuplicateMessage(message.type, message.data)) {
return;
}
// Store message for deduplication
this.lastMessages.set(message.type, {
data: message.data,
@@ -394,29 +406,170 @@ export class WebSocketService {
}
/**
* Send a message via Socket.IO
* Send a message via Socket.IO with optional operation tracking
*/
send(data: any): boolean {
send(data: unknown, trackOperation?: boolean): boolean | string {
if (!this.isConnected()) {
console.warn('Cannot send message: Socket.IO not connected');
return false;
}
try {
let operationId: string | undefined;
// Track operation if requested
if (trackOperation && this.operationTracker) {
const messageData = data as { type?: string };
operationId = this.operationTracker.createOperation(
messageData.type || 'message',
data
);
// Add operation ID to the message
const trackedData = { ...messageData, operationId };
data = trackedData;
}
// For Socket.IO, we emit events based on message type
if (data.type) {
this.socket!.emit(data.type, data.data || data);
const messageData = data as { type?: string; data?: unknown };
if (messageData.type) {
this.socket!.emit(messageData.type, messageData.data || data);
} else {
// Default message event
this.socket!.emit('message', data);
}
return true;
return operationId || true;
} catch (error) {
console.error('Failed to send message:', error);
return false;
}
}
// Enhanced emit method with automatic operation ID tracking for echo suppression
private pendingOperations = new Map<string, NodeJS.Timeout>();
emit(event: string, data: unknown): string {
const operationId = crypto.randomUUID();
const payload = { ...(typeof data === 'object' && data !== null ? data : {}), operationId };
// Track pending operation
const timeout = setTimeout(() => {
this.pendingOperations.delete(operationId);
}, 5000);
this.pendingOperations.set(operationId, timeout);
// Emit with operation ID
if (this.socket) {
this.socket.emit(event, payload);
}
return operationId;
}
/**
* Send a tracked operation and wait for response
*/
async sendTrackedOperation(data: unknown, timeout?: number): Promise<OperationResult> {
if (!this.operationTracker) {
throw new Error('Operation tracking not enabled');
}
const messageData = data as { type?: string };
const operationId = this.operationTracker.createOperation(
messageData.type || 'message',
data
);
return new Promise((resolve, reject) => {
// Set up operation handler
const timeoutId = setTimeout(() => {
this.operationHandlers.delete(operationId);
const result = this.operationTracker!.failOperation(
operationId,
'Operation timed out'
);
reject(new Error(result.error));
}, timeout || 30000);
this.operationHandlers.set(operationId, (result: OperationResult) => {
clearTimeout(timeoutId);
this.operationHandlers.delete(operationId);
if (result.success) {
resolve(result);
} else {
reject(new Error(result.error || 'Operation failed'));
}
});
// Send the tracked message
const trackedData = { ...messageData, operationId };
const sent = this.send(trackedData, false); // Don't double-track
if (!sent) {
clearTimeout(timeoutId);
this.operationHandlers.delete(operationId);
reject(new Error('Failed to send message'));
}
});
}
/**
* Handle operation response from server
*/
private handleOperationResponse(response: {
operationId: string;
success: boolean;
data?: unknown;
error?: string;
}): void {
if (!this.operationTracker) return;
const result = response.success
? this.operationTracker.completeOperation(response.operationId, response.data)
: this.operationTracker.failOperation(response.operationId, response.error || 'Unknown error');
// Notify handler if exists
const handler = this.operationHandlers.get(response.operationId);
if (handler) {
handler(result);
}
}
/**
* Unified method to check if a message should be processed
* Consolidates echo suppression and deduplication logic
*/
private shouldProcessMessage(message: WebSocketMessage): boolean {
// Check for operation ID echo suppression
if (message.data && typeof message.data === 'object' && 'operationId' in message.data) {
const operationId = (message.data as Record<string, unknown>).operationId as string;
// Check pending operations map first (for immediate echoes)
if (this.pendingOperations.has(operationId)) {
const timeout = this.pendingOperations.get(operationId);
if (timeout) clearTimeout(timeout);
this.pendingOperations.delete(operationId);
console.log(`[Socket] Suppressing echo for pending operation ${operationId}`);
return false;
}
// Check operation tracker (for tracked operations)
if (this.operationTracker?.shouldSuppress(operationId)) {
console.log(`[Socket] Suppressing tracked operation ${operationId}`);
return false;
}
}
// Check for duplicate messages
if (this.isDuplicateMessage(message.type, message.data)) {
return false;
}
return true;
}
/**
* Wait for connection to be established
*/
@@ -462,6 +615,38 @@ export class WebSocketService {
this.deduplicationWindow = windowMs;
}
/**
* Enable operation tracking
*/
enableOperationTracking(timeout?: number): void {
if (!this.operationTracker) {
this.operationTracker = new OperationTracker(timeout);
}
}
/**
* Disable operation tracking
*/
disableOperationTracking(): void {
if (this.operationTracker) {
this.operationTracker.destroy();
this.operationTracker = null;
this.operationHandlers.clear();
}
}
/**
* Get operation tracking statistics
*/
getOperationStats(): {
total: number;
pending: number;
completed: number;
failed: number;
} | null {
return this.operationTracker?.getStats() || null;
}
disconnect(): void {
this.setState(WebSocketState.DISCONNECTED);
@@ -478,6 +663,13 @@ export class WebSocketService {
this.connectionResolver = null;
this.connectionRejector = null;
this.lastMessages.clear(); // Clear deduplication cache
// Clean up operation tracking
if (this.operationTracker) {
this.operationTracker.destroy();
this.operationTracker = null;
}
this.operationHandlers.clear();
}
}
@@ -486,9 +678,28 @@ export function createWebSocketService(config?: WebSocketConfig): WebSocketServi
return new WebSocketService(config);
}
// Export singleton instances for different features
export const knowledgeSocketIO = new WebSocketService();
// Create SEPARATE WebSocket instances for different features
// This prevents a failure in one feature (like a long crawl) from breaking the entire site
export const knowledgeSocketIO = new WebSocketService({
maxReconnectAttempts: 10, // More attempts for crawls that can take a long time
reconnectInterval: 2000,
heartbeatInterval: 30000,
enableAutoReconnect: true
});
// Export instances for backward compatibility
export const taskUpdateSocketIO = new WebSocketService();
export const projectListSocketIO = new WebSocketService();
export const taskUpdateSocketIO = new WebSocketService({
maxReconnectAttempts: 5,
reconnectInterval: 1000,
heartbeatInterval: 30000,
enableAutoReconnect: true
});
export const projectListSocketIO = new WebSocketService({
maxReconnectAttempts: 5,
reconnectInterval: 1000,
heartbeatInterval: 30000,
enableAutoReconnect: true
});
// Export knowledgeSocketIO as default for backward compatibility
export default knowledgeSocketIO;

View 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;
}

View 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';

View File

@@ -11,17 +11,17 @@ export type UITaskStatus = 'backlog' | 'in-progress' | 'review' | 'complete';
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
// Assignee type - simplified to predefined options
export type Assignee = 'User' | 'Archon' | 'AI IDE Agent';
// Assignee type - flexible string to support MCP subagents
export type Assignee = string;
// Base Project interface (matches database schema)
export interface Project {
id: string;
title: string;
prd?: Record<string, any>; // JSONB field
docs?: any[]; // JSONB field
features?: any[]; // JSONB field
data?: any[]; // JSONB field
docs?: import('./jsonb').ProjectDocument[]; // Typed JSONB field
features?: import('./jsonb').ProjectFeature[]; // Typed JSONB field
data?: import('./jsonb').ProjectData[]; // Typed JSONB field
github_repo?: string;
created_at: string;
updated_at: string;
@@ -59,8 +59,8 @@ export interface Task {
assignee: Assignee; // Now a database column with enum constraint
task_order: number; // New database column for priority ordering
feature?: string; // New database column for feature name
sources?: any[]; // JSONB field
code_examples?: any[]; // JSONB field
sources?: import('./jsonb').TaskSource[]; // Typed JSONB field
code_examples?: import('./jsonb').TaskCodeExample[]; // Typed JSONB field
created_at: string;
updated_at: string;
@@ -85,9 +85,9 @@ export interface CreateProjectRequest {
pinned?: boolean;
// Note: PRD data should be stored as a document in the docs array with document_type="prd"
// not as a direct 'prd' field since this column doesn't exist in the database
docs?: any[];
features?: any[];
data?: any[];
docs?: import('./jsonb').ProjectDocument[];
features?: import('./jsonb').ProjectFeature[];
data?: import('./jsonb').ProjectData[];
technical_sources?: string[];
business_sources?: string[];
}
@@ -98,9 +98,9 @@ export interface UpdateProjectRequest {
description?: string;
github_repo?: string;
prd?: Record<string, any>;
docs?: any[];
features?: any[];
data?: any[];
docs?: import('./jsonb').ProjectDocument[];
features?: import('./jsonb').ProjectFeature[];
data?: import('./jsonb').ProjectData[];
technical_sources?: string[];
business_sources?: string[];
pinned?: boolean;
@@ -117,8 +117,8 @@ export interface CreateTaskRequest {
feature?: string;
featureColor?: string;
priority?: TaskPriority;
sources?: any[];
code_examples?: any[];
sources?: import('./jsonb').TaskSource[];
code_examples?: import('./jsonb').TaskCodeExample[];
}
// Update task request
@@ -131,8 +131,8 @@ export interface UpdateTaskRequest {
feature?: string;
featureColor?: string;
priority?: TaskPriority;
sources?: any[];
code_examples?: any[];
sources?: import('./jsonb').TaskSource[];
code_examples?: import('./jsonb').TaskCodeExample[];
}
// MCP tool response types

View 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')
);
}

View 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');

View 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;
}
}

View 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[]>);
}

View 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()
})
})

View 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')
})
})

View 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 } : {})
}))
}

View 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)
})
})

View 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
})
})
})

View File

@@ -18,9 +18,7 @@ from pydantic import BaseModel
logger = logging.getLogger(__name__)
# Import Socket.IO instance
from ..socketio_app import get_socketio_instance
sio = get_socketio_instance()
from ..socketio_app import sio
# Create router
router = APIRouter(prefix="/api/agent-chat", tags=["agent-chat"])

View File

@@ -35,7 +35,7 @@ from ..utils.document_processing import extract_text_from_document
# Get logger for this module
logger = get_logger(__name__)
from ..socketio_app import get_socketio_instance
from ..socketio_app import sio
from .socketio_handlers import (
complete_crawl_progress,
error_crawl_progress,
@@ -46,8 +46,6 @@ from .socketio_handlers import (
# Create router
router = APIRouter(prefix="/api", tags=["knowledge"])
# Get Socket.IO instance
sio = get_socketio_instance()
# Create a semaphore to limit concurrent crawls
# This prevents the server from becoming unresponsive during heavy crawling

View File

@@ -8,12 +8,10 @@ No other modules should import from this file.
import asyncio
from ..config.logfire_config import get_logger
from ..socketio_app import get_socketio_instance
from ..socketio_app import sio
logger = get_logger(__name__)
# Get Socket.IO instance
sio = get_socketio_instance()
# Core broadcast functions

View File

@@ -13,13 +13,10 @@ from ..config.logfire_config import get_logger
from ..services.background_task_manager import get_task_manager
from ..services.projects.project_service import ProjectService
from ..services.projects.source_linking_service import SourceLinkingService
from ..socketio_app import get_socketio_instance
from ..socketio_app import sio
logger = get_logger(__name__)
# Get Socket.IO instance
sio = get_socketio_instance()
logger.info(f"🔗 [SOCKETIO] Socket.IO instance ID: {id(sio)}")
# Rate limiting for Socket.IO broadcasts
_last_broadcast_times: dict[str, float] = {}

View File

@@ -128,7 +128,7 @@ class KnowledgeItemService:
code_example_counts[source_id] = 0
chunk_counts[source_id] = 0 # Default to 0 to avoid timeout
safe_logfire_info(f"Code example counts: {code_example_counts}")
safe_logfire_info("Code example counts", code_counts=code_example_counts)
# Transform sources to items with batched data
items = []
@@ -138,16 +138,18 @@ class KnowledgeItemService:
# Use batched data instead of individual queries
first_page_url = first_urls.get(source_id, f"source://{source_id}")
# Use original crawl URL instead of first page URL
original_url = source_metadata.get("original_url") or first_page_url
code_examples_count = code_example_counts.get(source_id, 0)
chunks_count = chunk_counts.get(source_id, 0)
# Determine source type
source_type = self._determine_source_type(source_metadata, first_page_url)
source_type = self._determine_source_type(source_metadata, original_url)
item = {
"id": source_id,
"title": source.get("title", source.get("summary", "Untitled")),
"url": first_page_url,
"url": original_url,
"source_id": source_id,
"code_examples": [{"count": code_examples_count}]
if code_examples_count > 0

View File

@@ -11,13 +11,10 @@ from datetime import datetime
from typing import Any
from ...config.logfire_config import get_logger
from ...socketio_app import get_socketio_instance
from ...socketio_app import sio
logger = get_logger(__name__)
# Get Socket.IO instance
sio = get_socketio_instance()
logger.info(f"🔗 [PROGRESS] Socket.IO instance ID: {id(sio)}")
class ProgressService:

View File

@@ -17,9 +17,7 @@ logger = get_logger(__name__)
# Import Socket.IO instance directly to avoid circular imports
try:
from ...socketio_app import get_socketio_instance
_sio = get_socketio_instance()
from ...socketio_app import sio as _sio
_broadcast_available = True
logger.info("✅ Socket.IO broadcasting is AVAILABLE - real-time updates enabled")

View File

@@ -870,14 +870,17 @@ async def add_code_examples_to_supabase(
# Prepare batch data - only for successful embeddings
batch_data = []
used_indices = set() # Track which indices have been mapped to prevent duplicates
for j, (embedding, text) in enumerate(
zip(valid_embeddings, successful_texts, strict=False)
):
# Find the original index
# Find the original index (skip already used indices)
orig_idx = None
for k, orig_text in enumerate(batch_texts):
if orig_text == text:
if orig_text == text and k not in used_indices:
orig_idx = k
used_indices.add(k) # Mark this index as used
break
if orig_idx is None:

View File

@@ -252,20 +252,23 @@ async def add_documents_to_supabase(
search_logger.warning(
f"Skipping batch {batch_num} - no successful embeddings created"
)
completed_batches += 1
# Don't increment completed_batches when skipping - this causes progress to jump
continue
# Prepare batch data - only for successful embeddings
batch_data = []
used_indices = set() # Track which indices have been mapped to prevent duplicates
# Map successful texts back to their original indices
for j, (embedding, text) in enumerate(
zip(batch_embeddings, successful_texts, strict=False)
):
# Find the original index of this text
# Find the original index of this text (skip already used indices)
orig_idx = None
for idx, orig_text in enumerate(contextual_contents):
if orig_text == text:
if orig_text == text and idx not in used_indices:
orig_idx = idx
used_indices.add(idx) # Mark this index as used
break
if orig_idx is None:
@@ -356,6 +359,9 @@ async def add_documents_to_supabase(
search_logger.info(
f"Individual inserts: {successful_inserts}/{len(batch_data)} successful"
)
# Even if we had to fall back to individual inserts, count this batch as processed
if successful_inserts > 0:
completed_batches += 1
# Minimal delay between batches to prevent overwhelming
if i + batch_size < len(contents):

View File

@@ -93,17 +93,18 @@ class RateLimiter:
self._clean_old_entries(now)
# Check if we can make the request
if not self._can_make_request(estimated_tokens):
while not self._can_make_request(estimated_tokens):
wait_time = self._calculate_wait_time(estimated_tokens)
if wait_time > 0:
logfire_logger.info(
f"Rate limiting: waiting {wait_time:.1f}s",
tokens=estimated_tokens,
current_usage=self._get_current_usage(),
f"Rate limiting: waiting {wait_time:.1f}s (tokens={estimated_tokens}, current_usage={self._get_current_usage()})"
)
await asyncio.sleep(wait_time)
return await self.acquire(estimated_tokens)
return False
# Clean old entries after waiting
now = time.time()
self._clean_old_entries(now)
else:
return False
# Record the request
self.request_times.append(now)
@@ -198,17 +199,13 @@ class MemoryAdaptiveDispatcher:
# Reduce workers when memory is high
workers = max(1, base // 2)
logfire_logger.warning(
"High memory usage detected, reducing workers",
memory_percent=metrics.memory_percent,
workers=workers,
f"High memory usage detected, reducing workers (memory_percent={metrics.memory_percent}, workers={workers})"
)
elif metrics.cpu_percent > self.config.cpu_threshold * 100:
# Reduce workers when CPU is high
workers = max(1, base // 2)
logfire_logger.warning(
"High CPU usage detected, reducing workers",
cpu_percent=metrics.cpu_percent,
workers=workers,
f"High CPU usage detected, reducing workers (cpu_percent={metrics.cpu_percent}, workers={workers})"
)
elif metrics.memory_percent < 50 and metrics.cpu_percent < 50:
# Increase workers when resources are available
@@ -238,12 +235,7 @@ class MemoryAdaptiveDispatcher:
semaphore = asyncio.Semaphore(optimal_workers)
logfire_logger.info(
"Starting adaptive processing",
items_count=len(items),
workers=optimal_workers,
mode=mode,
memory_percent=self.last_metrics.memory_percent,
cpu_percent=self.last_metrics.cpu_percent,
f"Starting adaptive processing (items_count={len(items)}, workers={optimal_workers}, mode={mode}, memory_percent={self.last_metrics.memory_percent}, cpu_percent={self.last_metrics.cpu_percent})"
)
# Track active workers
@@ -318,7 +310,7 @@ class MemoryAdaptiveDispatcher:
del active_workers[worker_id]
logfire_logger.error(
f"Processing failed for item {index}", error=str(e), item_index=index
f"Processing failed for item {index} (error={str(e)}, item_index={index})"
)
return None
@@ -333,11 +325,7 @@ class MemoryAdaptiveDispatcher:
success_rate = len(successful_results) / len(items) * 100
logfire_logger.info(
"Adaptive processing completed",
total_items=len(items),
successful=len(successful_results),
success_rate=f"{success_rate:.1f}%",
workers_used=optimal_workers,
f"Adaptive processing completed (total_items={len(items)}, successful={len(successful_results)}, success_rate={success_rate:.1f}%, workers_used={optimal_workers})"
)
return successful_results
@@ -355,7 +343,7 @@ class WebSocketSafeProcessor:
await websocket.accept()
self.active_connections.append(websocket)
logfire_logger.info(
"WebSocket client connected", total_connections=len(self.active_connections)
f"WebSocket client connected (total_connections={len(self.active_connections)})"
)
def disconnect(self, websocket: WebSocket):
@@ -363,7 +351,7 @@ class WebSocketSafeProcessor:
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logfire_logger.info(
"WebSocket client disconnected", remaining_connections=len(self.active_connections)
f"WebSocket client disconnected (remaining_connections={len(self.active_connections)})"
)
async def broadcast_progress(self, message: dict[str, Any]):
@@ -474,7 +462,7 @@ class ThreadingService:
self._running = True
self._health_check_task = asyncio.create_task(self._health_check_loop())
logfire_logger.info("Threading service started", config=self.config.__dict__)
logfire_logger.info(f"Threading service started (config={self.config.__dict__})")
async def stop(self):
"""Stop the threading service"""
@@ -510,7 +498,7 @@ class ThreadingService:
finally:
duration = time.time() - start_time
logfire_logger.debug(
"Rate limited operation completed", duration=duration, tokens=estimated_tokens
f"Rate limited operation completed (duration={duration}, tokens={estimated_tokens})"
)
async def run_cpu_intensive(self, func: Callable, *args, **kwargs) -> Any:
@@ -562,37 +550,30 @@ class ThreadingService:
# Log system metrics
logfire_logger.info(
"System health check",
memory_percent=metrics.memory_percent,
cpu_percent=metrics.cpu_percent,
available_memory_gb=metrics.available_memory_gb,
active_threads=metrics.active_threads,
active_websockets=len(self.websocket_processor.active_connections),
f"System health check (memory_percent={metrics.memory_percent}, cpu_percent={metrics.cpu_percent}, available_memory_gb={metrics.available_memory_gb}, active_threads={metrics.active_threads}, active_websockets={len(self.websocket_processor.active_connections)})"
)
# Alert on critical thresholds
if metrics.memory_percent > 90:
logfire_logger.warning(
"Critical memory usage", memory_percent=metrics.memory_percent
f"Critical memory usage (memory_percent={metrics.memory_percent})"
)
# Force garbage collection
gc.collect()
if metrics.cpu_percent > 95:
logfire_logger.warning("Critical CPU usage", cpu_percent=metrics.cpu_percent)
logfire_logger.warning(f"Critical CPU usage (cpu_percent={metrics.cpu_percent})")
# Check for memory leaks (too many threads)
if metrics.active_threads > self.config.max_workers * 3:
logfire_logger.warning(
"High thread count detected",
active_threads=metrics.active_threads,
max_expected=self.config.max_workers * 3,
f"High thread count detected (active_threads={metrics.active_threads}, max_expected={self.config.max_workers * 3})"
)
await asyncio.sleep(self.config.health_check_interval)
except Exception as e:
logfire_logger.error("Health check failed", error=str(e))
logfire_logger.error(f"Health check failed (error={str(e)})")
await asyncio.sleep(self.config.health_check_interval)

View File

@@ -29,7 +29,6 @@ sio = socketio.AsyncServer(
# Global Socket.IO instance for use across modules
_socketio_instance: socketio.AsyncServer | None = None
def get_socketio_instance() -> socketio.AsyncServer:
"""Get the global Socket.IO server instance."""
global _socketio_instance