- 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

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