POC: TanStack Query implementation with conditional devtools

- Replace manual useState polling with TanStack Query for projects/tasks
- Add comprehensive query key factories for cache management
- Implement optimistic updates with automatic rollback
- Create progress polling hooks with smart completion detection
- Add VITE_SHOW_DEVTOOLS environment variable for conditional devtools
- Remove legacy hooks: useDatabaseMutation, usePolling, useProjectMutation
- Update components to use mutation hooks directly (reduce prop drilling)
- Enhanced QueryClient with optimized polling and caching settings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Rasmus Widing
2025-09-03 09:46:48 +03:00
parent 277bfdaa71
commit af3485c980
20 changed files with 1723 additions and 1747 deletions

View File

@@ -42,6 +42,12 @@ ARCHON_DOCS_PORT=3838
# If not set, defaults to localhost, 127.0.0.1, ::1, and the HOST value above
VITE_ALLOWED_HOSTS=
# Development Tools
# VITE_SHOW_DEVTOOLS: Show TanStack Query DevTools (for developers only)
# Set to "true" to enable the DevTools panel in bottom right corner
# Defaults to "false" for end users
VITE_SHOW_DEVTOOLS=false
# When enabled, PROD mode will proxy ARCHON_SERVER_PORT through ARCHON_UI_PORT. This exposes both the
# Archon UI and API through a single port. This is useful when deploying Archon behind a reverse
# proxy where you want to expose the frontend on a single external domain.

View File

@@ -12,6 +12,8 @@
"@milkdown/kit": "^7.5.0",
"@milkdown/plugin-history": "^7.5.0",
"@milkdown/preset-commonmark": "^7.5.0",
"@tanstack/react-query": "^5.85.8",
"@tanstack/react-query-devtools": "^5.85.8",
"@xyflow/react": "^12.3.0",
"clsx": "latest",
"date-fns": "^4.1.0",
@@ -2571,6 +2573,59 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.85.7",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.7.tgz",
"integrity": "sha512-FLT3EtuTbXBmOrDku4bI80Eivmjn/o/Zc1lVEd/6yzR8UAUSnDwYiwghCZvLqHyGSN5mO35ux1yPGMFYBFRSwA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.84.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz",
"integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.85.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.8.tgz",
"integrity": "sha512-r3rW55STAO03EJg5mrCVIJvaEK3oeHme5u7QovuRFIKRbEgTzTv2DPdenX46X+x56LsU3ree1N4rzI/+gJ7KEA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.85.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.85.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.8.tgz",
"integrity": "sha512-83SXqRpmVlRMpaj32veez/8ohjY7O4VQIYDqW91b4i9AQjiYgE24FbBfR/SOL8b5MfKhHMZkD+BQSpCh9jY06w==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.84.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.85.8",
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",

View File

@@ -22,6 +22,8 @@
"@milkdown/kit": "^7.5.0",
"@milkdown/plugin-history": "^7.5.0",
"@milkdown/preset-commonmark": "^7.5.0",
"@tanstack/react-query": "^5.85.8",
"@tanstack/react-query-devtools": "^5.85.8",
"@xyflow/react": "^12.3.0",
"clsx": "latest",
"date-fns": "^4.1.0",

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
import { SettingsPage } from './pages/SettingsPage';
import { MCPPage } from './pages/MCPPage';
@@ -15,6 +17,28 @@ import { MigrationBanner } from './components/ui/MigrationBanner';
import { serverHealthService } from './services/serverHealthService';
import { useMigrationStatus } from './hooks/useMigrationStatus';
// Create a client with optimized settings for our polling use case
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Keep data fresh for 2 seconds by default
staleTime: 2000,
// Cache data for 5 minutes
gcTime: 5 * 60 * 1000,
// Retry failed requests 3 times
retry: 3,
// Refetch on window focus
refetchOnWindowFocus: true,
// Don't refetch on reconnect by default (we handle this manually)
refetchOnReconnect: false,
},
mutations: {
// Retry mutations once on failure
retry: 1,
},
},
});
const AppRoutes = () => {
const { projectsEnabled } = useSettings();
@@ -105,12 +129,17 @@ const AppContent = () => {
export function App() {
return (
<ThemeProvider>
<ToastProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</ToastProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</ToastProvider>
</ThemeProvider>
{import.meta.env.VITE_SHOW_DEVTOOLS === 'true' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}

View File

@@ -26,7 +26,7 @@ import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { CrawlProgressData } from '../../types/crawl';
import { useCrawlProgressPolling } from '../../hooks/usePolling';
import { useCrawlProgressPolling } from '../../hooks/useCrawlQueries';
import { useTerminalScroll } from '../../hooks/useTerminalScroll';
interface CrawlingProgressCardProps {

View File

@@ -8,7 +8,7 @@ import { Input } from '../ui/Input';
import { Card } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { Select } from '../ui/Select';
import { useCrawlProgressPolling } from '../../hooks/usePolling';
import { useCrawlProgressPolling } from '../../hooks/useCrawlQueries';
import { MilkdownEditor } from './MilkdownEditor';
import { VersionHistoryModal } from './VersionHistoryModal';
import { PRPViewer } from '../prp';

View File

@@ -145,12 +145,6 @@ export const DraggableTaskCard = ({
<Tag className="w-3 h-3" />
{task.feature}
</div>
{/* Task order display */}
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold text-white ${getOrderColor(task.task_order)}`}>
{task.task_order}
</div>
{/* Action buttons group */}
<div className="ml-auto flex items-center gap-1.5">
<button

View File

@@ -3,7 +3,7 @@ import { X } from 'lucide-react';
import { Button } from '../ui/Button';
import { ArchonLoadingSpinner } from '../animations/Animations';
import { DebouncedInput, FeatureInput } from './TaskInputComponents';
import type { Task } from './TaskTableView';
import type { Task, Assignee } from '../../types/project';
interface EditTaskModalProps {
isModalOpen: boolean;
@@ -12,14 +12,12 @@ interface EditTaskModalProps {
isLoadingFeatures: boolean;
isSavingTask: boolean;
onClose: () => void;
onSave: (task: Task) => Promise<void>;
getTasksForPrioritySelection: (status: Task['status']) => Array<{value: number, label: string}>;
onSave: (task: Partial<Task>) => Promise<void>;
getTasksForPrioritySelection?: (status: Task['status']) => Array<{value: number, label: string}>;
}
const ASSIGNEE_OPTIONS = ['User', 'Archon', 'AI IDE Agent'] as const;
// Removed debounce utility - now using DebouncedInput component
export const EditTaskModal = memo(({
isModalOpen,
editingTask,
@@ -30,51 +28,50 @@ export const EditTaskModal = memo(({
onSave,
getTasksForPrioritySelection
}: EditTaskModalProps) => {
const [localTask, setLocalTask] = useState<Task | null>(null);
// Diagnostic: Track render count
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
console.log(`[EditTaskModal] Render #${renderCount.current}`, {
localTask: localTask?.title,
isModalOpen,
timestamp: Date.now()
});
});
const [localTask, setLocalTask] = useState<Partial<Task> | null>(null);
// Sync local state with editingTask when it changes
useEffect(() => {
if (editingTask) {
setLocalTask(editingTask);
} else {
// Reset for new task
setLocalTask({
title: '',
description: '',
status: 'todo',
assignee: 'User' as Assignee,
feature: '',
task_order: 100
});
}
}, [editingTask]);
const priorityOptions = useMemo(() => {
console.log(`[EditTaskModal] Recalculating priorityOptions for status: ${localTask?.status || 'todo'}`);
if (!getTasksForPrioritySelection) {
// Fallback if function not provided
return [{ value: 100, label: 'Default Priority' }];
}
return getTasksForPrioritySelection(localTask?.status || 'todo');
}, [localTask?.status, getTasksForPrioritySelection]);
// Memoized handlers for input changes
const handleTitleChange = useCallback((value: string) => {
console.log('[EditTaskModal] Title changed via DebouncedInput:', value);
setLocalTask(prev => prev ? { ...prev, title: value } : null);
}, []);
const handleDescriptionChange = useCallback((value: string) => {
console.log('[EditTaskModal] Description changed via DebouncedInput:', value);
setLocalTask(prev => prev ? { ...prev, description: value } : null);
}, []);
const handleFeatureChange = useCallback((value: string) => {
console.log('[EditTaskModal] Feature changed via FeatureInput:', value);
setLocalTask(prev => prev ? { ...prev, feature: value } : null);
}, []);
const handleStatusChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const newStatus = e.target.value as Task['status'];
const newOrder = getTasksForPrioritySelection(newStatus)[0]?.value || 1;
const newOrder = getTasksForPrioritySelection ?
getTasksForPrioritySelection(newStatus)[0]?.value || 100 : 100;
setLocalTask(prev => prev ? { ...prev, status: newStatus, task_order: newOrder } : null);
}, [getTasksForPrioritySelection]);
@@ -85,7 +82,7 @@ export const EditTaskModal = memo(({
const handleAssigneeChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setLocalTask(prev => prev ? {
...prev,
assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' }
assignee: e.target.value as Assignee
} : null);
}, []);
@@ -153,7 +150,7 @@ export const EditTaskModal = memo(({
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select
value={localTask?.task_order || 1}
value={localTask?.task_order || 100}
onChange={handlePriorityChange}
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-white rounded-md py-2 px-3 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
>
@@ -168,7 +165,7 @@ export const EditTaskModal = memo(({
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Assignee</label>
<select
value={localTask?.assignee?.name || 'User'}
value={localTask?.assignee || 'User'}
onChange={handleAssigneeChange}
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-white rounded-md py-2 px-3 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
>
@@ -192,23 +189,21 @@ export const EditTaskModal = memo(({
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button onClick={handleClose} variant="ghost" disabled={isSavingTask}>Cancel</Button>
<Button
onClick={handleSave}
variant="primary"
accentColor="cyan"
className="shadow-lg shadow-cyan-500/20"
disabled={isSavingTask}
variant="primary"
className="flex items-center gap-2"
disabled={isSavingTask || !localTask?.title}
>
{isSavingTask ? (
<span className="flex items-center">
<ArchonLoadingSpinner size="sm" className="mr-2" />
{localTask?.id ? 'Saving...' : 'Creating...'}
</span>
<>
<ArchonLoadingSpinner size="sm" />
<span>Saving...</span>
</>
) : (
localTask?.id ? 'Save Changes' : 'Create Task'
<span>{editingTask?.id ? 'Update Task' : 'Create Task'}</span>
)}
</Button>
</div>
@@ -216,28 +211,6 @@ export const EditTaskModal = memo(({
</div>
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
// Only re-render if these specific props change
const isEqual = (
prevProps.isModalOpen === nextProps.isModalOpen &&
prevProps.editingTask?.id === nextProps.editingTask?.id &&
prevProps.editingTask?.title === nextProps.editingTask?.title &&
prevProps.editingTask?.description === nextProps.editingTask?.description &&
prevProps.editingTask?.status === nextProps.editingTask?.status &&
prevProps.editingTask?.assignee?.name === nextProps.editingTask?.assignee?.name &&
prevProps.editingTask?.feature === nextProps.editingTask?.feature &&
prevProps.editingTask?.task_order === nextProps.editingTask?.task_order &&
prevProps.isSavingTask === nextProps.isSavingTask &&
prevProps.isLoadingFeatures === nextProps.isLoadingFeatures &&
prevProps.projectFeatures === nextProps.projectFeatures // Reference equality check
);
if (!isEqual) {
console.log('[EditTaskModal] Props changed, re-rendering');
}
return isEqual;
});
EditTaskModal.displayName = 'EditTaskModal';

View File

@@ -7,19 +7,11 @@ import { projectService } from '../../services/projectService';
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
import { DraggableTaskCard } from './DraggableTaskCard';
export interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'doing' | 'review' | 'done';
assignee: {
name: 'User' | 'Archon' | 'AI IDE Agent';
avatar: string;
};
feature: string;
featureColor: string;
task_order: number;
}
// Import Task from types instead of redefining
import type { Task, Assignee } from '../../types/project';
// Re-export Task for components that import from here
export type { Task } from '../../types/project';
interface TaskTableViewProps {
tasks: Task[];
@@ -78,7 +70,7 @@ const reorderTasks = (tasks: Task[], fromIndex: number, toIndex: number): Task[]
interface EditableCellProps {
value: string;
onSave: (value: string) => void;
type?: 'text' | 'textarea' | 'select';
type?: 'text' | 'textarea' | 'select' | 'assignee';
options?: string[];
placeholder?: string;
isEditing: boolean;
@@ -96,14 +88,19 @@ const EditableCell = ({
onEdit,
onCancel
}: EditableCellProps) => {
const [editValue, setEditValue] = useState(value);
const [editValue, setEditValue] = useState(value || '');
// Update editValue when value prop changes
React.useEffect(() => {
setEditValue(value || '');
}, [value]);
const handleSave = () => {
onSave(editValue);
};
const handleCancel = () => {
setEditValue(value);
setEditValue(value || '');
onCancel();
};
@@ -124,6 +121,22 @@ const EditableCell = ({
};
if (!isEditing) {
// Special display for assignee type
if (type === 'assignee') {
return (
<div
onClick={onEdit}
className="cursor-pointer flex items-center justify-center"
title={`Assignee: ${value}`}
>
<div className={`flex items-center gap-2 px-2 py-1 rounded-full border transition-all duration-300 hover:scale-105 ${getAssigneeGlassStyle(value as any)} ${getAssigneeGlow(value as any)}`}>
{getAssigneeIcon(value as any)}
<span className="text-xs">{value}</span>
</div>
</div>
);
}
return (
<div
onClick={onEdit}
@@ -139,7 +152,7 @@ const EditableCell = ({
return (
<div className="flex items-center w-full">
{type === 'select' ? (
{(type === 'select' || type === 'assignee') ? (
<select
value={editValue}
onChange={(e) => {
@@ -252,7 +265,8 @@ 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: '' };
// Assignee is a string in the Task interface
updates.assignee = value as Assignee;
} else if (field === 'feature') {
updates.feature = value;
}
@@ -281,18 +295,11 @@ const DraggableTaskRow = ({
onMouseLeave={() => setIsHovering(false)}
style={style}
>
<td className="p-3">
<div className="flex items-center justify-center">
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold transition-all duration-300 ${getOrderGlassStyle(task.task_order)} ${getOrderTextColor(task.task_order)} ${getOrderGlow(task.task_order)}`}>
{task.task_order}
</div>
</div>
</td>
<td className="p-3 text-gray-800 dark:text-gray-200 group-hover:text-gray-900 dark:group-hover:text-white transition-colors relative">
<div className="min-w-0 flex items-center">
<div className="truncate flex-1">
<EditableCell
value={task.title}
value={task.title || ''}
onSave={(value) => handleUpdateField('title', value)}
isEditing={editingField === 'title'}
onEdit={() => setEditingField('title')}
@@ -304,7 +311,7 @@ const DraggableTaskRow = ({
</td>
<td className="p-3">
<EditableCell
value={task.status}
value={task.status || 'todo'}
onSave={(value) => {
handleUpdateField('status', value as Task['status']);
}}
@@ -318,7 +325,7 @@ const DraggableTaskRow = ({
<td className="p-3">
<div className="truncate">
<EditableCell
value={task.feature}
value={task.feature || ''}
onSave={(value) => handleUpdateField('feature', value)}
isEditing={editingField === 'feature'}
onEdit={() => setEditingField('feature')}
@@ -328,32 +335,15 @@ 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 || 'User'}
onSave={(value) => handleUpdateField('assignee', value)}
type="assignee"
options={['User', 'Archon', 'AI IDE Agent']}
isEditing={editingField === 'assignee'}
onEdit={() => setEditingField('assignee')}
onCancel={() => setEditingField(null)}
/>
</td>
<td className="p-3">
<div className="flex justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -422,7 +412,7 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
title: '',
description: '',
status: statusFilter === 'all' ? 'todo' : statusFilter,
assignee: { name: 'AI IDE Agent', avatar: '' },
assignee: 'AI IDE Agent' as Assignee,
feature: '',
featureColor: '#3b82f6',
task_order: 1
@@ -434,7 +424,7 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
// Calculate the next order number for the target status
const targetStatus = newTask.status;
const tasksInStatus = tasks.filter(t => t.status === targetStatus);
const nextOrder = tasksInStatus.length > 0 ? Math.max(...tasksInStatus.map(t => t.task_order)) + 1 : 1;
const nextOrder = tasksInStatus.length > 0 ? Math.max(...tasksInStatus.map(t => t.task_order)) + 100 : 100;
try {
await onTaskCreate({
@@ -442,11 +432,12 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
task_order: nextOrder
});
// Reset only the title to allow quick adding
// Reset the form for quick adding
setNewTask(prev => ({
...prev,
title: '',
description: ''
description: '',
feature: ''
}));
} catch (error) {
console.error('Failed to create task:', error);
@@ -471,29 +462,27 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
<>
<tr className="border-t border-cyan-400 dark:border-cyan-500 bg-cyan-50/30 dark:bg-cyan-900/10 relative">
{/* Toned down neon blue line separator */}
<td colSpan={6} className="p-0 relative">
<td colSpan={5} className="p-0 relative">
<div className="absolute inset-x-0 top-0 h-[1px] bg-gradient-to-r from-transparent via-cyan-400 to-transparent shadow-[0_0_4px_1px_rgba(34,211,238,0.4)] dark:shadow-[0_0_6px_2px_rgba(34,211,238,0.5)]"></div>
</td>
</tr>
<tr className="bg-cyan-50/20 dark:bg-cyan-900/5">
<td className="p-3">
<div className="flex items-center justify-center">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]">
+
</div>
<input
type="text"
value={newTask.title}
onChange={(e) => setNewTask(prev => ({ ...prev, title: e.target.value }))}
onKeyPress={handleKeyPress}
placeholder="Type task title and press Enter..."
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)] transition-all duration-200"
autoFocus
/>
</div>
</td>
<td className="p-3">
<input
type="text"
value={newTask.title}
onChange={(e) => setNewTask(prev => ({ ...prev, title: e.target.value }))}
onKeyPress={handleKeyPress}
placeholder="Type task title and press Enter..."
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)] transition-all duration-200"
autoFocus
/>
</td>
<td className="p-3">
<select
value={newTask.status}
@@ -520,10 +509,10 @@ const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
</td>
<td className="p-3">
<select
value={newTask.assignee.name}
value={newTask.assignee}
onChange={(e) => setNewTask(prev => ({
...prev,
assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' }
assignee: e.target.value as Assignee
}))}
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)]"
>
@@ -551,7 +540,7 @@ export const TaskTableView = ({
onTaskCreate,
onTaskUpdate
}: TaskTableViewProps) => {
const [statusFilter, setStatusFilter] = useState<Task['status'] | 'all'>('todo');
const [statusFilter, setStatusFilter] = useState<Task['status'] | 'all'>('all');
// State for delete confirmation modal
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -775,7 +764,6 @@ export const TaskTableView = ({
>
<table ref={tableRef} className="w-full border-collapse table-fixed">
<colgroup>
<col className="w-16" />
<col className="w-auto" />
<col className="w-24" />
<col className="w-28" />
@@ -784,14 +772,6 @@ export const TaskTableView = ({
</colgroup>
<thead>
<tr className="bg-white/80 dark:bg-black/80 backdrop-blur-sm sticky top-0 z-10">
<th className="text-left p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center gap-2">
<span className={getHeaderColor('secondary')}>Order</span>
<span className={`w-1 h-1 rounded-full ${getHeaderGlow('secondary')}`}></span>
</div>
{/* Header divider with glow matching board view */}
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] bg-purple-500/30 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)]`}></div>
</th>
<th className="text-left p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center gap-2">
<span className={getHeaderColor('primary')}>Task</span>

View File

@@ -1,83 +1,42 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, LayoutGrid, Plus } from 'lucide-react';
import React, { useState, useMemo, useCallback } from 'react';
import { Plus, Table, LayoutGrid } from 'lucide-react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Toggle } from '../ui/Toggle';
import { projectService } from '../../services/projectService';
import { debounce } from 'lodash';
import { useToast } from '../../contexts/ToastContext';
import { debounce } from '../../utils/debounce';
import { calculateReorderPosition, getDefaultTaskOrder } from '../../utils/taskOrdering';
import {
useProjectTasks,
useProjectFeatures,
useCreateTask,
useUpdateTask,
useDeleteTask
} from '../../hooks/useProjectQueries';
import type { CreateTaskRequest, UpdateTaskRequest } from '../../types/project';
import { TaskTableView, Task } from './TaskTableView';
import { TaskBoardView } from './TaskBoardView';
import { EditTaskModal } from './EditTaskModal';
// Type for optimistic task updates with operation tracking
type OptimisticTask = Task & { _optimisticOperationId: string };
export const TasksTab = ({
initialTasks,
onTasksChange,
projectId
}: {
initialTasks: Task[];
onTasksChange: (tasks: Task[]) => void;
projectId: string;
}) => {
export const TasksTab = ({ projectId }: { projectId: string }) => {
const { showToast } = useToast();
const [viewMode, setViewMode] = useState<'table' | 'board'>('board');
const [tasks, setTasks] = useState<Task[]>([]);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [projectFeatures, setProjectFeatures] = useState<any[]>([]);
const [isLoadingFeatures, setIsLoadingFeatures] = useState(false);
const [isSavingTask, setIsSavingTask] = useState<boolean>(false);
const [optimisticTaskUpdates, setOptimisticTaskUpdates] = useState<Map<string, OptimisticTask>>(new Map());
// Initialize tasks, but preserve optimistic updates
useEffect(() => {
if (optimisticTaskUpdates.size === 0) {
// No optimistic updates, use incoming data as-is
setTasks(initialTasks);
} else {
// Merge incoming data with optimistic updates
const mergedTasks = initialTasks.map(task => {
const optimisticUpdate = optimisticTaskUpdates.get(task.id);
if (optimisticUpdate) {
console.log(`[TasksTab] Preserving optimistic update for task ${task.id}:`, optimisticUpdate.status);
// Clean up internal tracking field before returning
const { _optimisticOperationId, ...cleanTask } = optimisticUpdate;
return cleanTask as Task; // Keep optimistic version without internal fields
}
return task; // Use polling data for non-optimistic tasks
});
setTasks(mergedTasks);
}
}, [initialTasks, optimisticTaskUpdates]);
// Fetch tasks and features using TanStack Query
const { data: tasks = [], isLoading: isLoadingTasks } = useProjectTasks(projectId);
const { data: featuresData, isLoading: isLoadingFeatures } = useProjectFeatures(projectId);
// Mutations
const createTaskMutation = useCreateTask();
const updateTaskMutation = useUpdateTask(projectId);
const deleteTaskMutation = useDeleteTask(projectId);
// Load project features on component mount
useEffect(() => {
loadProjectFeatures();
}, [projectId]);
const loadProjectFeatures = async () => {
if (!projectId) return;
setIsLoadingFeatures(true);
try {
const response = await projectService.getProjectFeatures(projectId);
setProjectFeatures(response.features || []);
} catch (error) {
console.error('Failed to load project features:', error);
setProjectFeatures([]);
} finally {
setIsLoadingFeatures(false);
}
};
// Transform features data
const projectFeatures = useMemo(() => {
return featuresData?.features || [];
}, [featuresData]);
// Modal management functions
const openEditModal = async (task: Task) => {
@@ -85,356 +44,242 @@ export const TasksTab = ({
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
const openCreateModal = () => {
setEditingTask(null);
setIsModalOpen(true);
};
const saveTask = async (task: Task) => {
setEditingTask(task);
const closeModal = () => {
setEditingTask(null);
setIsModalOpen(false);
};
// Get default order for new tasks in a status
const getDefaultTaskOrder = (statusTasks: Task[], status: Task['status']) => {
if (statusTasks.length === 0) return 100;
const maxOrder = Math.max(...statusTasks.map(t => t.task_order));
return maxOrder + 100;
};
// Calculate position between two tasks for reordering
const calculateReorderPosition = (statusTasks: Task[], fromIndex: number, toIndex: number) => {
// Moving to the beginning
if (toIndex === 0) {
return Math.max(1, Math.floor(statusTasks[0].task_order / 2));
}
// Moving to the end
if (toIndex >= statusTasks.length) {
return statusTasks[statusTasks.length - 1].task_order + 100;
}
// Moving between two tasks
// When moving down (fromIndex < toIndex), insert after toIndex
// When moving up (fromIndex > toIndex), insert before toIndex
if (fromIndex < toIndex) {
// Moving down - insert after toIndex
const afterTask = statusTasks[toIndex];
const nextTask = statusTasks[toIndex + 1];
if (nextTask) {
return Math.floor((afterTask.task_order + nextTask.task_order) / 2);
} else {
return afterTask.task_order + 100;
}
} else {
// Moving up - insert before toIndex
const beforeTask = toIndex > 0 ? statusTasks[toIndex - 1] : null;
const targetTask = statusTasks[toIndex];
if (beforeTask) {
return Math.floor((beforeTask.task_order + targetTask.task_order) / 2);
} else {
return Math.max(1, Math.floor(targetTask.task_order / 2));
}
}
};
// Save task (create or update)
const saveTask = async (taskData: Partial<Task>) => {
setIsSavingTask(true);
try {
if (task.id) {
// Update existing task
const updateData: UpdateTaskRequest = {
title: task.title,
description: task.description,
status: task.status,
assignee: task.assignee?.name || 'User',
task_order: task.task_order,
...(task.feature && { feature: task.feature }),
...(task.featureColor && { featureColor: task.featureColor })
};
if (editingTask) {
// Update existing task - build updates object with only changed values
const updates: any = {};
await projectService.updateTask(task.id, updateData);
// Only include fields that are defined (not null or undefined)
if (taskData.title !== undefined) updates.title = taskData.title;
if (taskData.description !== undefined) updates.description = taskData.description;
if (taskData.status !== undefined) updates.status = taskData.status;
if (taskData.assignee !== undefined) updates.assignee = taskData.assignee || 'User';
if (taskData.task_order !== undefined) updates.task_order = taskData.task_order;
// Feature can be empty string but not null/undefined
if (taskData.feature !== undefined && taskData.feature !== null) {
updates.feature = taskData.feature || ''; // Convert empty/null to empty string
}
await updateTaskMutation.mutateAsync({
taskId: editingTask.id,
updates
});
closeModal();
} else {
// Create new task first to get UUID
const createData: CreateTaskRequest = {
// Create new task
const statusTasks = tasks.filter(t => t.status === (taskData.status || 'todo'));
const newTaskData: CreateTaskRequest = {
project_id: projectId,
title: task.title,
description: task.description,
status: task.status,
assignee: task.assignee?.name || 'User',
task_order: task.task_order,
...(task.feature && { feature: task.feature }),
...(task.featureColor && { featureColor: task.featureColor })
title: taskData.title || '',
description: taskData.description || '',
status: taskData.status || 'todo',
assignee: taskData.assignee || 'User',
feature: taskData.feature || '',
task_order: taskData.task_order || getDefaultTaskOrder(statusTasks, taskData.status || 'todo')
};
await projectService.createTask(createData);
await createTaskMutation.mutateAsync(newTaskData);
closeModal();
}
// Task saved - polling will pick up changes automatically
closeModal();
} catch (error) {
console.error('Failed to save task:', error);
showToast(`Failed to save task: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
showToast('Failed to save task', 'error');
} finally {
setIsSavingTask(false);
}
};
// Update tasks helper
const updateTasks = (newTasks: Task[]) => {
setTasks(newTasks);
onTasksChange(newTasks);
};
// Helper function to get next available order number for a status
const getNextOrderForStatus = (status: Task['status']): number => {
const tasksInStatus = tasks.filter(task =>
task.status === status
);
if (tasksInStatus.length === 0) return 1;
const maxOrder = Math.max(...tasksInStatus.map(task => task.task_order));
return maxOrder + 1;
};
// Use shared debounce helper
// Improved debounced persistence with better coordination
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
await projectService.updateTask(task.id, {
task_order: task.task_order,
client_timestamp: Date.now()
});
console.log('REORDER: Single task position persisted successfully');
} catch (error) {
console.error('REORDER: Failed to persist task position:', error);
// Polling will eventually sync the correct state
}
}, 800), // Slightly reduced delay for better responsiveness
[]
);
// Optimized task reordering without optimistic update conflicts
const handleTaskReorder = useCallback((taskId: string, targetIndex: number, status: Task['status']) => {
console.log('REORDER: Moving task', taskId, 'to index', targetIndex, 'in status', status);
// Task reordering - immediate update
const handleTaskReorder = useCallback(async (taskId: string, targetIndex: number, status: Task['status']) => {
// Get all tasks in the target status, sorted by current order
const statusTasks = tasks
.filter(task => task.status === status)
.sort((a, b) => a.task_order - b.task_order);
const otherTasks = tasks.filter(task => task.status !== status);
// Find the moving task
const movingTaskIndex = statusTasks.findIndex(task => task.id === taskId);
if (movingTaskIndex === -1) {
console.log('REORDER: Task not found in status');
return;
}
if (movingTaskIndex === -1 || targetIndex < 0 || targetIndex >= statusTasks.length) return;
if (movingTaskIndex === targetIndex) return;
// Prevent invalid moves
if (targetIndex < 0 || targetIndex >= statusTasks.length) {
console.log('REORDER: Invalid target index', targetIndex);
return;
}
// Skip if moving to same position
if (movingTaskIndex === targetIndex) {
console.log('REORDER: Task already in target position');
return;
}
const movingTask = statusTasks[movingTaskIndex];
console.log('REORDER: Moving', movingTask.title, 'from', movingTaskIndex, 'to', targetIndex);
// Calculate new position using shared ordering utility
// Calculate new position
const newPosition = calculateReorderPosition(statusTasks, movingTaskIndex, targetIndex);
console.log('REORDER: New position calculated:', newPosition);
// Create updated task with new position
const updatedTask = {
...movingTask,
task_order: newPosition
};
// Immediate UI update without optimistic tracking interference
const allUpdatedTasks = otherTasks.concat(
statusTasks.map(task => task.id === taskId ? updatedTask : task)
);
updateTasks(allUpdatedTasks);
// Persist to backend (single API call)
debouncedPersistSingleTask(updatedTask);
}, [tasks, updateTasks, debouncedPersistSingleTask]);
// Task move function (for board view) - Optimistic Updates with Concurrent Operation Protection
const moveTask = async (taskId: string, newStatus: Task['status']) => {
// Generate unique operation ID to handle concurrent operations
const operationId = `${taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
console.log(`[TasksTab] Optimistically moving task ${taskId} to ${newStatus} (op: ${operationId})`);
// Clear any previous errors (removed local error state)
// Find the task and validate
const movingTask = tasks.find(task => task.id === taskId);
if (!movingTask) {
showToast('Task not found', 'error');
return;
}
// (pendingOperations removed)
// 1. Save current state for rollback
const previousTasks = [...tasks]; // Shallow clone sufficient
const newOrder = getNextOrderForStatus(newStatus);
// 2. Update UI immediately (optimistic update - no loader!)
const optimisticTask: OptimisticTask = {
...movingTask,
status: newStatus,
task_order: newOrder,
_optimisticOperationId: operationId // Track which operation created this
};
const optimisticTasks = tasks.map(task =>
task.id === taskId ? optimisticTask : task
);
// Track this as an optimistic update with operation ID
setOptimisticTaskUpdates(prev => new Map(prev).set(taskId, optimisticTask));
updateTasks(optimisticTasks);
// 3. Call API in background
// Update immediately with optimistic updates
try {
await projectService.updateTask(taskId, {
status: newStatus,
task_order: newOrder,
client_timestamp: Date.now()
});
console.log(`[TasksTab] Successfully moved task ${taskId} (op: ${operationId})`);
// Only clear if this is still the current operation (no newer operation started)
setOptimisticTaskUpdates(prev => {
const currentOptimistic = prev.get(taskId);
if (currentOptimistic?._optimisticOperationId === operationId) {
const newMap = new Map(prev);
newMap.delete(taskId);
return newMap;
await updateTaskMutation.mutateAsync({
taskId,
updates: {
task_order: newPosition
}
return prev; // Don't clear, newer operation is active
});
} catch (error) {
console.error(`[TasksTab] Failed to move task ${taskId} (op: ${operationId}):`, error);
console.error('Failed to reorder task:', error);
showToast('Failed to reorder task', 'error');
}
}, [tasks, updateTaskMutation, showToast]);
// Move task to different status
const moveTask = async (taskId: string, newStatus: Task['status']) => {
const movingTask = tasks.find(task => task.id === taskId);
if (!movingTask || movingTask.status === newStatus) return;
try {
// Calculate position for new status
const tasksInNewStatus = tasks.filter(t => t.status === newStatus);
const newOrder = getDefaultTaskOrder(tasksInNewStatus, newStatus);
// Only rollback if this is still the current operation
setOptimisticTaskUpdates(prev => {
const currentOptimistic = prev.get(taskId);
if (currentOptimistic?._optimisticOperationId === operationId) {
// 4. Rollback on failure - revert to exact previous state
updateTasks(previousTasks);
const newMap = new Map(prev);
newMap.delete(taskId);
const errorMessage = error instanceof Error ? error.message : 'Failed to move task';
showToast(`Failed to move task: ${errorMessage}`, 'error');
return newMap;
// Update via mutation (handles optimistic updates)
await updateTaskMutation.mutateAsync({
taskId,
updates: {
status: newStatus,
task_order: newOrder
}
return prev; // Don't rollback, newer operation is active
});
} finally {
// (pendingOperations cleanup removed)
showToast(`Task moved to ${newStatus}`, 'success');
} catch (error) {
console.error('Failed to move task:', error);
showToast('Failed to move task', 'error');
}
};
const completeTask = (taskId: string) => {
console.log(`[TasksTab] Calling completeTask for ${taskId}`);
const completeTask = useCallback((taskId: string) => {
moveTask(taskId, 'done');
};
}, []);
const deleteTask = async (task: Task) => {
try {
await projectService.deleteTask(task.id);
updateTasks(tasks.filter(t => t.id !== task.id));
showToast(`Task "${task.title}" deleted`, 'success');
await deleteTaskMutation.mutateAsync(task.id);
} catch (error) {
console.error('Failed to delete task:', error);
showToast('Failed to delete task', 'error');
// Error handled by mutation
}
};
// Inline task creation function
const createTaskInline = async (newTask: Omit<Task, 'id'>) => {
try {
// Auto-assign next order number if not provided
const nextOrder = newTask.task_order || getNextOrderForStatus(newTask.status);
const createData: CreateTaskRequest = {
project_id: projectId,
title: newTask.title,
description: newTask.description,
status: newTask.status,
assignee: newTask.assignee?.name || 'User',
task_order: nextOrder,
...(newTask.feature && { feature: newTask.feature }),
...(newTask.featureColor && { featureColor: newTask.featureColor })
};
await projectService.createTask(createData);
// Task created - polling will pick up changes automatically
console.log('[TasksTab] Task created successfully');
} catch (error) {
console.error('Failed to create task:', error);
throw error;
}
};
// Inline task update function
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()
};
if (updates.title !== undefined) updateData.title = updates.title;
if (updates.description !== undefined) updateData.description = updates.description;
if (updates.status !== undefined) {
console.log(`[TasksTab] Setting status for ${taskId}: ${updates.status}`);
updateData.status = updates.status;
}
if (updates.assignee !== undefined) updateData.assignee = updates.assignee.name;
if (updates.task_order !== undefined) updateData.task_order = updates.task_order;
if (updates.feature !== undefined) updateData.feature = updates.feature;
if (updates.featureColor !== undefined) updateData.featureColor = updates.featureColor;
console.log(`[TasksTab] Sending update request for task ${taskId} to projectService:`, updateData);
await projectService.updateTask(taskId, updateData);
console.log(`[TasksTab] projectService.updateTask successful for ${taskId}.`);
// Task updated - polling will pick up changes automatically
console.log(`[TasksTab] Task ${taskId} updated successfully`);
} catch (error) {
console.error(`[TasksTab] Failed to update task ${taskId} inline:`, error);
showToast(`Failed to update task: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
throw error;
}
};
// Get tasks for priority selection with descriptive labels
const getTasksForPrioritySelection = (status: Task['status']): Array<{value: number, label: string}> => {
// Get task priority selection options
const getTasksForPrioritySelection = useCallback((status: Task['status']) => {
const tasksInStatus = tasks
.filter(task => task.status === status && task.id !== editingTask?.id) // Exclude current task if editing
.filter(task => task.status === status && task.id !== editingTask?.id)
.sort((a, b) => a.task_order - b.task_order);
const options: Array<{value: number, label: string}> = [];
if (tasksInStatus.length === 0) {
// No tasks in this status
options.push({ value: 1, label: "1 - First task in this status" });
options.push({ value: 100, label: "First task in this status" });
} else {
// Add option to be first
options.push({
value: 1,
label: `1 - Before "${tasksInStatus[0].title.substring(0, 30)}${tasksInStatus[0].title.length > 30 ? '...' : ''}"`
value: Math.max(1, Math.floor(tasksInStatus[0].task_order / 2)),
label: `Before "${tasksInStatus[0].title.substring(0, 30)}${tasksInStatus[0].title.length > 30 ? '...' : ''}"`
});
// Add options between existing tasks
for (let i = 0; i < tasksInStatus.length - 1; i++) {
const currentTask = tasksInStatus[i];
const nextTask = tasksInStatus[i + 1];
const midPoint = Math.floor((currentTask.task_order + nextTask.task_order) / 2);
options.push({
value: i + 2,
label: `${i + 2} - After "${currentTask.title.substring(0, 20)}${currentTask.title.length > 20 ? '...' : ''}", Before "${nextTask.title.substring(0, 20)}${nextTask.title.length > 20 ? '...' : ''}"`
value: midPoint,
label: `Between "${currentTask.title.substring(0, 20)}${currentTask.title.length > 20 ? '...' : ''}" and "${nextTask.title.substring(0, 20)}${nextTask.title.length > 20 ? '...' : ''}"`
});
}
// Add option to be last
const lastTask = tasksInStatus[tasksInStatus.length - 1];
options.push({
value: tasksInStatus.length + 1,
label: `${tasksInStatus.length + 1} - After "${lastTask.title.substring(0, 30)}${lastTask.title.length > 30 ? '...' : ''}"`
value: lastTask.task_order + 100,
label: `After "${lastTask.title.substring(0, 30)}${lastTask.title.length > 30 ? '...' : ''}"`
});
}
return options;
}, [tasks, editingTask?.id]);
// Inline update for task fields
const updateTaskInline = async (taskId: string, updates: Partial<Task>) => {
try {
// Ensure task_order is an integer if present
const processedUpdates: any = { ...updates };
if (processedUpdates.task_order !== undefined) {
processedUpdates.task_order = Math.round(processedUpdates.task_order);
}
// Assignee is already a string, no conversion needed
await updateTaskMutation.mutateAsync({
taskId,
updates: processedUpdates
});
} catch (error) {
console.error('Failed to update task:', error);
showToast('Failed to update task', 'error');
}
};
// Memoized version of getTasksForPrioritySelection to prevent recalculation on every render
const memoizedGetTasksForPrioritySelection = useMemo(
() => getTasksForPrioritySelection,
[tasks, editingTask?.id]
);
if (isLoadingTasks) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<DndProvider backend={HTML5Backend}>
@@ -448,16 +293,23 @@ export const TasksTab = ({
onTaskComplete={completeTask}
onTaskDelete={deleteTask}
onTaskReorder={handleTaskReorder}
onTaskCreate={createTaskInline}
onTaskCreate={async (task) => {
await createTaskMutation.mutateAsync({
...task,
project_id: projectId,
assignee: task.assignee || 'User', // Already a string
task_order: Math.round(task.task_order) // Ensure integer
});
}}
onTaskUpdate={updateTaskInline}
/>
) : (
<TaskBoardView
tasks={tasks}
onTaskView={openEditModal}
onTaskMove={moveTask}
onTaskComplete={completeTask}
onTaskDelete={deleteTask}
onTaskMove={moveTask}
onTaskReorder={handleTaskReorder}
/>
)}
@@ -470,17 +322,9 @@ export const TasksTab = ({
{/* Add Task Button with Luminous Style */}
<button
onClick={() => {
const defaultOrder = getDefaultTaskOrder(tasks.filter(t => t.status === 'todo'));
setEditingTask({
id: '',
title: '',
description: '',
status: 'todo',
assignee: { name: 'AI IDE Agent', avatar: '' },
feature: '',
featureColor: '#3b82f6',
task_order: defaultOrder
});
const statusTasks = tasks.filter(t => t.status === 'todo');
const defaultOrder = getDefaultTaskOrder(statusTasks, 'todo');
setEditingTask(null);
setIsModalOpen(true);
}}
className="relative px-5 py-2.5 flex items-center gap-2 bg-white/80 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-lg shadow-[0_0_20px_rgba(0,0,0,0.1)] dark:shadow-[0_0_20px_rgba(0,0,0,0.5)] backdrop-blur-md pointer-events-auto text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 transition-all duration-300"
@@ -512,7 +356,7 @@ export const TasksTab = ({
</div>
</div>
{/* Edit Task Modal */}
{/* Edit/Create Task Modal */}
<EditTaskModal
isModalOpen={isModalOpen}
editingTask={editingTask}
@@ -521,7 +365,7 @@ export const TasksTab = ({
isSavingTask={isSavingTask}
onClose={closeModal}
onSave={saveTask}
getTasksForPrioritySelection={memoizedGetTasksForPrioritySelection}
getTasksForPrioritySelection={getTasksForPrioritySelection}
/>
</div>
</DndProvider>

View File

@@ -0,0 +1,433 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useCallback } from 'react';
import { knowledgeBaseService, KnowledgeItem } from '../services/knowledgeBaseService';
import { CrawlProgressData } from '../types/crawl';
import { useToast } from '../contexts/ToastContext';
// Query keys factory
export const crawlKeys = {
all: ['crawl'] as const,
progress: (progressId: string) => [...crawlKeys.all, 'progress', progressId] as const,
};
export const knowledgeKeys = {
all: ['knowledge'] as const,
items: () => [...knowledgeKeys.all, 'items'] as const,
item: (id: string) => [...knowledgeKeys.all, 'item', id] as const,
search: (query: string) => [...knowledgeKeys.all, 'search', query] as const,
};
// Fetch crawl progress
export function useCrawlProgressPolling(progressId: string | null, options?: any) {
const [isComplete, setIsComplete] = useState(false);
// Reset complete state when progressId changes
useEffect(() => {
console.log(`📊 Progress ID changed to: ${progressId}, resetting complete state`);
setIsComplete(false);
}, [progressId]);
const handleError = useCallback((error: Error) => {
// Handle permanent resource not found
if (error.message === 'Resource no longer exists') {
console.log(`Crawl progress no longer exists for: ${progressId}`);
// Clean up from localStorage
if (progressId) {
localStorage.removeItem(`crawl_progress_${progressId}`);
const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]');
const updated = activeCrawls.filter((id: string) => id !== progressId);
localStorage.setItem('active_crawls', JSON.stringify(updated));
}
options?.onError?.(error);
return;
}
// Log other errors
if (!error.message.includes('404') && !error.message.includes('Not Found') &&
!error.message.includes('ERR_INSUFFICIENT_RESOURCES')) {
console.error('Crawl progress error:', error);
}
options?.onError?.(error);
}, [progressId, options]);
const query = useQuery({
queryKey: crawlKeys.progress(progressId!),
queryFn: async () => {
if (!progressId) throw new Error('No progress ID');
const response = await fetch(`/api/progress/${progressId}`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (response.status === 404) {
// Track consecutive 404s
const notFoundKey = `crawl_404_${progressId}`;
const notFoundCount = parseInt(localStorage.getItem(notFoundKey) || '0') + 1;
localStorage.setItem(notFoundKey, notFoundCount.toString());
if (notFoundCount >= 5) {
localStorage.removeItem(notFoundKey);
throw new Error('Resource no longer exists');
}
console.log(`Resource not found (404), attempt ${notFoundCount}/5: ${progressId}`);
return null;
}
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
// Reset 404 counter on success
localStorage.removeItem(`crawl_404_${progressId}`);
return response.json();
},
enabled: !!progressId && !isComplete,
refetchInterval: 1000, // Poll every second
retry: false, // Don't retry on error
staleTime: 0, // Always refetch
onError: handleError,
});
// Stop polling when operation is complete or failed
useEffect(() => {
const status = query.data?.status;
if (query.data) {
console.debug('🔄 Crawl polling data received:', {
progressId,
status,
progress: query.data.progress
});
}
if (status === 'completed' || status === 'failed' || status === 'error' || status === 'cancelled') {
console.debug('⏹️ Crawl polling stopping - status:', status);
setIsComplete(true);
}
}, [query.data?.status, progressId]);
// Transform data to expected format
const transformedData = query.data ? {
...query.data,
progress: query.data.progress || 0,
logs: query.data.logs || [],
message: query.data.message || '',
} : null;
return {
...query,
data: transformedData,
isComplete
};
}
// ==================== KNOWLEDGE BASE QUERIES ====================
// Fetch knowledge items
export function useKnowledgeItems(page = 1, perPage = 100) {
return useQuery({
queryKey: knowledgeKeys.items(),
queryFn: async () => {
const response = await knowledgeBaseService.getKnowledgeItems({
page,
per_page: perPage
});
return response;
},
staleTime: 30000, // Consider data stale after 30 seconds
cacheTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
});
}
// Delete knowledge item mutation
export function useDeleteKnowledgeItem() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: async (sourceId: string) => {
return await knowledgeBaseService.deleteKnowledgeItem(sourceId);
},
onSuccess: (data, sourceId) => {
// Optimistically update the cache
queryClient.setQueryData(knowledgeKeys.items(), (old: any) => {
if (!old) return old;
return {
...old,
items: old.items.filter((item: KnowledgeItem) => item.source_id !== sourceId),
total: old.total - 1
};
});
showToast('Item deleted successfully', 'success');
},
onError: (error) => {
showToast('Failed to delete item', 'error');
console.error('Delete failed:', error);
}
});
}
// Delete multiple items mutation
export function useDeleteMultipleItems() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: async (sourceIds: string[]) => {
const deletePromises = sourceIds.map(id =>
knowledgeBaseService.deleteKnowledgeItem(id)
);
return await Promise.all(deletePromises);
},
onSuccess: (data, sourceIds) => {
// Optimistically update the cache
queryClient.setQueryData(knowledgeKeys.items(), (old: any) => {
if (!old) return old;
const idsSet = new Set(sourceIds);
return {
...old,
items: old.items.filter((item: KnowledgeItem) => !idsSet.has(item.source_id)),
total: old.total - sourceIds.length
};
});
showToast(`Deleted ${sourceIds.length} items successfully`, 'success');
},
onError: (error) => {
showToast('Failed to delete some items', 'error');
console.error('Batch delete failed:', error);
}
});
}
// Refresh knowledge item mutation
export function useRefreshKnowledgeItem() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: async (sourceId: string) => {
return await knowledgeBaseService.refreshKnowledgeItem(sourceId);
},
onSuccess: (data, sourceId) => {
// Remove the item from cache as it's being refreshed
queryClient.setQueryData(knowledgeKeys.items(), (old: any) => {
if (!old) return old;
return {
...old,
items: old.items.filter((item: KnowledgeItem) => item.source_id !== sourceId)
};
});
showToast('Refresh started', 'info');
},
onError: (error) => {
showToast('Failed to refresh item', 'error');
console.error('Refresh failed:', error);
}
});
}
// Crawl URL mutation
export function useCrawlUrl() {
const { showToast } = useToast();
return useMutation({
mutationFn: async (params: any) => {
return await knowledgeBaseService.crawlUrl(params);
},
onSuccess: (data) => {
if (data.progressId) {
showToast('Crawl started successfully', 'success');
}
},
onError: (error) => {
showToast('Failed to start crawl', 'error');
console.error('Crawl failed:', error);
}
});
}
// Upload document mutation
export function useUploadDocument() {
const { showToast } = useToast();
return useMutation({
mutationFn: async ({ file, metadata }: { file: File, metadata: any }) => {
return await knowledgeBaseService.uploadDocument(file, metadata);
},
onSuccess: (data) => {
if (data.progressId) {
showToast('Document upload started', 'success');
}
},
onError: (error) => {
showToast('Failed to upload document', 'error');
console.error('Upload failed:', error);
}
});
}
// Stop crawl mutation
export function useStopCrawl() {
const { showToast } = useToast();
return useMutation({
mutationFn: async (progressId: string) => {
return await knowledgeBaseService.stopCrawl(progressId);
},
onSuccess: () => {
showToast('Crawl stopped', 'info');
},
onError: (error) => {
showToast('Failed to stop crawl', 'error');
console.error('Stop crawl failed:', error);
}
});
}
// Create group mutation
export function useCreateGroup() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: async ({ items, groupName }: { items: KnowledgeItem[], groupName: string }) => {
const updatePromises = items.map(item =>
knowledgeBaseService.updateKnowledgeItem(item.source_id, {
metadata: {
...item.metadata,
group_name: groupName
}
})
);
return await Promise.all(updatePromises);
},
onSuccess: (data, variables) => {
// Invalidate the cache to refetch with new groups
queryClient.invalidateQueries({ queryKey: knowledgeKeys.items() });
showToast(`Created group "${variables.groupName}" with ${variables.items.length} items`, 'success');
},
onError: (error) => {
showToast('Failed to create group', 'error');
console.error('Group creation failed:', error);
}
});
}
// Custom hook to manage crawl progress state
export function useCrawlProgressManager() {
const [progressItems, setProgressItems] = useState<CrawlProgressData[]>([]);
const queryClient = useQueryClient();
// Load active crawls from localStorage on mount
useEffect(() => {
const activeCrawlsStr = localStorage.getItem('active_crawls');
const activeCrawls = JSON.parse(activeCrawlsStr || '[]');
if (activeCrawls.length > 0) {
const restoredItems: CrawlProgressData[] = [];
const staleItems: string[] = [];
for (const crawlId of activeCrawls) {
const crawlData = localStorage.getItem(`crawl_progress_${crawlId}`);
if (crawlData) {
try {
const parsed = JSON.parse(crawlData);
const isCompleted = ['completed', 'error', 'failed', 'cancelled'].includes(parsed.status);
const now = Date.now();
const startedAt = parsed.startedAt || now;
const ageMinutes = (now - startedAt) / (1000 * 60);
const isStale = ageMinutes > 5;
if (isCompleted || isStale) {
staleItems.push(crawlId);
} else {
restoredItems.push({
...parsed,
progressId: crawlId,
});
}
} catch {
staleItems.push(crawlId);
}
} else {
staleItems.push(crawlId);
}
}
// Clean up stale items
if (staleItems.length > 0) {
const updatedCrawls = activeCrawls.filter((id: string) => !staleItems.includes(id));
localStorage.setItem('active_crawls', JSON.stringify(updatedCrawls));
staleItems.forEach(id => {
localStorage.removeItem(`crawl_progress_${id}`);
});
}
// Set restored items
if (restoredItems.length > 0) {
setProgressItems(restoredItems);
}
}
}, []);
const addProgressItem = useCallback((item: CrawlProgressData) => {
setProgressItems(prev => {
const existing = prev.find(p => p.progressId === item.progressId);
if (existing) {
return prev.map(p => p.progressId === item.progressId ? item : p);
}
return [...prev, item];
});
// Store in localStorage
localStorage.setItem(`crawl_progress_${item.progressId}`, JSON.stringify({
...item,
startedAt: Date.now()
}));
const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]');
if (!activeCrawls.includes(item.progressId)) {
activeCrawls.push(item.progressId);
localStorage.setItem('active_crawls', JSON.stringify(activeCrawls));
}
}, []);
const removeProgressItem = useCallback((progressId: string) => {
setProgressItems(prev => prev.filter(item => item.progressId !== progressId));
// Clean up localStorage
localStorage.removeItem(`crawl_progress_${progressId}`);
const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]');
const updated = activeCrawls.filter((id: string) => id !== progressId);
localStorage.setItem('active_crawls', JSON.stringify(updated));
}, []);
const updateProgressItem = useCallback((progressId: string, updates: Partial<CrawlProgressData>) => {
setProgressItems(prev => prev.map(item =>
item.progressId === progressId ? { ...item, ...updates } : item
));
}, []);
const completeProgressItem = useCallback((progressId: string) => {
removeProgressItem(progressId);
// Invalidate knowledge items to show the new item
queryClient.invalidateQueries({ queryKey: knowledgeKeys.items() });
}, [removeProgressItem, queryClient]);
return {
progressItems,
addProgressItem,
removeProgressItem,
updateProgressItem,
completeProgressItem,
};
}

View File

@@ -1,194 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseDatabaseMutationOptions<TData, TVariables> {
onSuccess?: (data: TData) => void;
onError?: (error: Error) => void;
invalidateCache?: () => void;
successMessage?: string;
errorMessage?: string;
showSuccessToast?: boolean;
showErrorToast?: boolean;
}
interface UseDatabaseMutationResult<TData, TVariables> {
mutate: (variables: TVariables) => Promise<void>;
mutateAsync: (variables: TVariables) => Promise<TData>;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
error: Error | null;
data: TData | undefined;
reset: () => void;
}
/**
* Database-first mutation hook with loading states and error handling
*
* Features:
* - Shows loading state during operation
* - Waits for database confirmation before UI update
* - Displays errors immediately for debugging
* - Invalidates related queries after success
* - NO optimistic updates
*/
export function useDatabaseMutation<TData = unknown, TVariables = unknown>(
mutationFn: (variables: TVariables) => Promise<TData>,
options: UseDatabaseMutationOptions<TData, TVariables> = {}
): UseDatabaseMutationResult<TData, TVariables> {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [data, setData] = useState<TData | undefined>(undefined);
// Track if component is still mounted to prevent state updates after unmount
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const {
onSuccess,
onError,
invalidateCache,
successMessage = 'Operation completed successfully',
errorMessage = 'Operation failed',
showSuccessToast = false,
showErrorToast = true,
} = options;
const reset = useCallback(() => {
if (isMountedRef.current) {
setIsLoading(false);
setIsError(false);
setIsSuccess(false);
setError(null);
setData(undefined);
}
}, []);
const mutateAsync = useCallback(async (variables: TVariables): Promise<TData> => {
// Only update state if still mounted
if (isMountedRef.current) {
setIsLoading(true);
setIsError(false);
setIsSuccess(false);
setError(null);
}
try {
const result = await mutationFn(variables);
// Only update state and call callbacks if still mounted
if (isMountedRef.current) {
setData(result);
setIsSuccess(true);
// Invalidate cache if specified
if (invalidateCache) {
invalidateCache();
}
// Call success callback if provided
if (onSuccess) {
onSuccess(result);
}
// Show success toast if enabled
if (showSuccessToast && typeof window !== 'undefined' && (window as any).toast) {
(window as any).toast.success(successMessage);
}
}
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
// Only update state and call callbacks if still mounted
if (isMountedRef.current) {
setError(error);
setIsError(true);
// Call error callback if provided
if (onError) {
onError(error);
}
// Show error toast if enabled (default)
if (showErrorToast && typeof window !== 'undefined' && (window as any).toast) {
(window as any).toast.error(`${errorMessage}: ${error.message}`);
}
// Log for debugging in beta
console.error('Database operation failed:', error);
}
throw error;
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [mutationFn, onSuccess, onError, invalidateCache, successMessage, errorMessage, showSuccessToast, showErrorToast]);
const mutate = useCallback(async (variables: TVariables): Promise<void> => {
try {
await mutateAsync(variables);
} catch {
// Error already handled in mutateAsync
}
}, [mutateAsync]);
return {
mutate,
mutateAsync,
isLoading,
isError,
isSuccess,
error,
data,
reset,
};
}
/**
* Hook for mutations with inline loading indicator
*/
export function useAsyncMutation<TData = unknown, TVariables = unknown>(
mutationFn: (variables: TVariables) => Promise<TData>
) {
const [isLoading, setIsLoading] = useState(false);
// Track if component is still mounted to prevent state updates after unmount
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const execute = useCallback(async (variables: TVariables): Promise<TData | undefined> => {
if (isMountedRef.current) {
setIsLoading(true);
}
try {
const result = await mutationFn(variables);
return result;
} catch (error) {
console.error('Async mutation failed:', error);
throw error;
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [mutationFn]);
return { execute, isLoading };
}

View File

@@ -0,0 +1,77 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { mcpServerService } from '../services/mcpServerService';
import { useToast } from '../contexts/ToastContext';
// Query keys
export const mcpKeys = {
all: ['mcp'] as const,
status: () => [...mcpKeys.all, 'status'] as const,
config: () => [...mcpKeys.all, 'config'] as const,
tools: () => [...mcpKeys.all, 'tools'] as const,
};
// Fetch MCP server status
export function useMCPStatus() {
return useQuery({
queryKey: mcpKeys.status(),
queryFn: () => mcpServerService.getStatus(),
staleTime: 5 * 60 * 1000, // 5 minutes - status rarely changes
refetchOnWindowFocus: false,
});
}
// Fetch MCP server config
export function useMCPConfig(enabled = true) {
return useQuery({
queryKey: mcpKeys.config(),
queryFn: () => mcpServerService.getConfiguration(),
enabled,
staleTime: Infinity, // Config never changes unless server restarts
});
}
// Start server mutation
export function useStartMCPServer() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: () => mcpServerService.startServer(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpKeys.status() });
queryClient.invalidateQueries({ queryKey: mcpKeys.config() });
showToast('MCP server started successfully', 'success');
},
onError: (error: any) => {
showToast(error.message || 'Failed to start server', 'error');
},
});
}
// Stop server mutation
export function useStopMCPServer() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: () => mcpServerService.stopServer(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpKeys.status() });
queryClient.removeQueries({ queryKey: mcpKeys.config() });
showToast('MCP server stopped', 'info');
},
onError: (error: any) => {
showToast(error.message || 'Failed to stop server', 'error');
},
});
}
// List MCP tools
export function useMCPTools(enabled = true) {
return useQuery({
queryKey: mcpKeys.tools(),
queryFn: () => mcpServerService.listTools(),
enabled,
staleTime: Infinity, // Tools don't change during runtime
});
}

View File

@@ -1,338 +0,0 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
interface UsePollingOptions<T> {
interval?: number;
enabled?: boolean;
onError?: (error: Error) => void;
onSuccess?: (data: T) => void;
staleTime?: number;
}
interface UsePollingResult<T> {
data: T | undefined;
error: Error | null;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
refetch: () => Promise<void>;
}
/**
* Generic polling hook with visibility and focus detection
*
* Features:
* - Stops polling when tab is hidden
* - Resumes polling when tab becomes visible
* - Immediate refetch on focus
* - ETag support for efficient polling
*/
export function usePolling<T>(
url: string,
options: UsePollingOptions<T> = {}
): UsePollingResult<T> {
const {
interval = 3000,
enabled = true,
onError,
onSuccess,
staleTime = 0
} = options;
const [data, setData] = useState<T | undefined>(undefined);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [pollInterval, setPollInterval] = useState(enabled ? interval : 0);
const etagRef = useRef<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cachedDataRef = useRef<T | undefined>(undefined);
const lastFetchRef = useRef<number>(0);
const notFoundCountRef = useRef<number>(0); // Track consecutive 404s
// Reset ETag/cache on URL change to avoid cross-endpoint contamination
useEffect(() => {
etagRef.current = null;
cachedDataRef.current = undefined;
lastFetchRef.current = 0;
}, [url]);
const fetchData = useCallback(async (force = false) => {
// Don't fetch if URL is empty
if (!url) {
return;
}
// Check stale time
if (!force && staleTime > 0 && Date.now() - lastFetchRef.current < staleTime) {
return; // Data is still fresh
}
try {
const headers: HeadersInit = {
Accept: 'application/json',
};
// Include ETag if we have one for this URL (unless forcing refresh)
if (etagRef.current && !force) {
headers['If-None-Match'] = etagRef.current;
}
const response = await fetch(url, {
method: 'GET',
headers,
credentials: 'include',
});
// Handle 304 Not Modified - data hasn't changed
if (response.status === 304) {
// Return cached data
if (cachedDataRef.current !== undefined) {
setData(cachedDataRef.current);
if (onSuccess) {
onSuccess(cachedDataRef.current);
}
}
// Update fetch time to respect staleTime
lastFetchRef.current = Date.now();
return;
}
if (!response.ok) {
// For 404s, track consecutive failures
if (response.status === 404) {
notFoundCountRef.current++;
// After 5 consecutive 404s (5 seconds), stop polling and call error handler
if (notFoundCountRef.current >= 5) {
console.error(`Resource permanently not found after ${notFoundCountRef.current} attempts: ${url}`);
const error = new Error('Resource no longer exists');
setError(error);
setPollInterval(0); // Stop polling
if (onError) {
onError(error);
}
return;
}
console.log(`Resource not found (404), attempt ${notFoundCountRef.current}/5: ${url}`);
lastFetchRef.current = Date.now();
setError(null);
return;
}
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
// Reset 404 counter on successful response
notFoundCountRef.current = 0;
// Store ETag for next request
const etag = response.headers.get('ETag');
if (etag) {
etagRef.current = etag;
}
const jsonData = await response.json();
setData(jsonData);
cachedDataRef.current = jsonData;
lastFetchRef.current = Date.now();
setError(null);
// Call success callback if provided
if (onSuccess) {
onSuccess(jsonData);
}
} catch (err) {
console.error('Polling error:', err);
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
if (onError) {
onError(error);
}
} finally {
setIsLoading(false);
}
}, [url, staleTime, onSuccess, onError]);
// Handle visibility change
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
setPollInterval(0); // Stop polling when hidden
} else {
setPollInterval(interval); // Resume polling when visible
// Trigger immediate refetch if URL exists
if (url && enabled) {
fetchData();
}
}
};
const handleFocus = () => {
// Immediate refetch on focus if URL exists
if (url && enabled) {
fetchData();
}
setPollInterval(interval);
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
};
}, [interval, fetchData, url, enabled]);
// Update polling interval when enabled changes
useEffect(() => {
setPollInterval(enabled && !document.hidden ? interval : 0);
}, [enabled, interval]);
// Set up polling
useEffect(() => {
if (!url || !enabled) return;
// Initial fetch
fetchData();
// Clear existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Set up new interval if polling is enabled
if (pollInterval > 0) {
intervalRef.current = setInterval(fetchData, pollInterval);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [url, pollInterval, enabled, fetchData]);
return {
data,
error,
isLoading,
isError: !!error,
isSuccess: !isLoading && !error && data !== undefined,
refetch: () => fetchData(true)
};
}
/**
* Hook for polling task updates
*/
export function useTaskPolling(projectId: string, options?: UsePollingOptions<any>) {
const baseUrl = '/api/projects';
const url = `${baseUrl}/${projectId}/tasks`;
return usePolling(url, {
interval: 8000, // 8 seconds for tasks
staleTime: 2000, // Consider data stale after 2 seconds
...options,
});
}
/**
* Hook for polling project list
*/
export function useProjectPolling(options?: UsePollingOptions<any>) {
const url = '/api/projects';
return usePolling(url, {
interval: 10000, // 10 seconds for project list
staleTime: 3000, // Consider data stale after 3 seconds
...options,
});
}
/**
* Hook for polling crawl progress updates
*/
export function useCrawlProgressPolling(progressId: string | null, options?: UsePollingOptions<any>) {
const url = progressId ? `/api/progress/${progressId}` : '';
console.log(`🔍 useCrawlProgressPolling called with progressId: ${progressId}, url: ${url}`);
// Track if crawl is complete to disable polling
const [isComplete, setIsComplete] = useState(false);
// Reset complete state when progressId changes
useEffect(() => {
console.log(`📊 Progress ID changed to: ${progressId}, resetting complete state`);
setIsComplete(false);
}, [progressId]);
// Memoize the error handler to prevent recreating it on every render
const handleError = useCallback((error: Error) => {
// Handle permanent resource not found (after 5 consecutive 404s)
if (error.message === 'Resource no longer exists') {
console.log(`Crawl progress no longer exists for: ${progressId}`);
// Clean up from localStorage
if (progressId) {
localStorage.removeItem(`crawl_progress_${progressId}`);
const activeCrawls = JSON.parse(localStorage.getItem('active_crawls') || '[]');
const updated = activeCrawls.filter((id: string) => id !== progressId);
localStorage.setItem('active_crawls', JSON.stringify(updated));
}
// Pass error to parent if provided
options?.onError?.(error);
return;
}
// Log other errors
if (!error.message.includes('404') && !error.message.includes('Not Found') &&
!error.message.includes('ERR_INSUFFICIENT_RESOURCES')) {
console.error('Crawl progress error:', error);
}
// Pass error to parent if provided
options?.onError?.(error);
}, [progressId, options]);
const result = usePolling(url, {
interval: 1000, // 1 second for crawl progress
enabled: !!progressId && !isComplete,
staleTime: 0, // Always refetch progress
onError: handleError,
});
// Stop polling when operation is complete or failed
useEffect(() => {
const status = result.data?.status;
if (result.data) {
console.debug('🔄 Crawl polling data received:', {
progressId,
status,
progress: result.data.progress
});
}
if (status === 'completed' || status === 'failed' || status === 'error' || status === 'cancelled') {
console.debug('⏹️ Crawl polling stopping - status:', status);
setIsComplete(true);
}
}, [result.data?.status, progressId]);
// Backend now returns flattened, camelCase response - no transformation needed!
const transformedData = result.data ? {
...result.data,
// Ensure we have required fields with defaults
progress: result.data.progress || 0,
logs: result.data.logs || [],
message: result.data.message || '',
} : null;
return {
...result,
data: transformedData,
isComplete
};
}

View File

@@ -1,125 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useToast } from '../contexts/ToastContext';
interface UseProjectMutationOptions<TData, TVariables> {
onSuccess?: (data: TData, variables: TVariables) => void;
onError?: (error: Error) => void;
successMessage?: string;
errorMessage?: string;
}
interface UseProjectMutationResult<TData, TVariables> {
mutate: (variables: TVariables) => Promise<void>;
mutateAsync: (variables: TVariables) => Promise<TData>;
isPending: boolean;
isError: boolean;
isSuccess: boolean;
error: Error | null;
data: TData | undefined;
}
/**
* Project-specific mutation hook
* Similar to useDatabaseMutation but tailored for project operations
*/
export function useProjectMutation<TData = unknown, TVariables = unknown>(
_key: unknown, // For compatibility with old API, not used
mutationFn: (variables: TVariables) => Promise<TData>,
options: UseProjectMutationOptions<TData, TVariables> = {}
): UseProjectMutationResult<TData, TVariables> {
const { showToast } = useToast();
const [isPending, setIsPending] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [data, setData] = useState<TData | undefined>(undefined);
// Track if component is still mounted to prevent state updates after unmount
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const {
onSuccess,
onError,
successMessage = 'Operation completed successfully',
errorMessage = 'Operation failed',
} = options;
const mutateAsync = useCallback(async (variables: TVariables): Promise<TData> => {
// Only update state if still mounted
if (isMountedRef.current) {
setIsPending(true);
setIsError(false);
setIsSuccess(false);
setError(null);
}
try {
const result = await mutationFn(variables);
// Only update state and call callbacks if still mounted
if (isMountedRef.current) {
setData(result);
setIsSuccess(true);
// Call success callback if provided
if (onSuccess) {
onSuccess(result, variables);
}
// Show success message if available
if (successMessage) {
showToast(successMessage, 'success');
}
}
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
// Only update state and call callbacks if still mounted
if (isMountedRef.current) {
setError(error);
setIsError(true);
// Call error callback if provided
if (onError) {
onError(error);
}
// Show error message
showToast(errorMessage, 'error');
}
throw error;
} finally {
if (isMountedRef.current) {
setIsPending(false);
}
}
}, [mutationFn, onSuccess, onError, successMessage, errorMessage]);
const mutate = useCallback(async (variables: TVariables): Promise<void> => {
try {
await mutateAsync(variables);
} catch {
// Error already handled in mutateAsync
}
}, [mutateAsync]);
return {
mutate,
mutateAsync,
isPending,
isError,
isSuccess,
error,
data,
};
}

View File

@@ -0,0 +1,254 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectService } from '../services/projectService';
import type { Project, CreateProjectRequest, UpdateProjectRequest } from '../types/project';
import type { Task } from '../components/project-tasks/TaskTableView';
import { useToast } from '../contexts/ToastContext';
// Query keys factory for better organization
export const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
list: (filters?: any) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, 'detail'] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
tasks: (projectId: string) => [...projectKeys.detail(projectId), 'tasks'] as const,
taskCounts: () => ['taskCounts'] as const,
features: (projectId: string) => [...projectKeys.detail(projectId), 'features'] as const,
};
// Fetch all projects
export function useProjects() {
return useQuery({
queryKey: projectKeys.lists(),
queryFn: () => projectService.listProjects(),
refetchInterval: 10000, // Poll every 10 seconds
staleTime: 3000, // Consider data stale after 3 seconds
});
}
// Fetch tasks for a specific project
export function useProjectTasks(projectId: string | undefined, enabled = true) {
return useQuery({
queryKey: projectKeys.tasks(projectId!),
queryFn: () => projectService.getTasksByProject(projectId!),
enabled: !!projectId && enabled,
refetchInterval: 8000, // Poll every 8 seconds
staleTime: 2000, // Consider data stale after 2 seconds
});
}
// Fetch task counts for all projects
export function useTaskCounts() {
return useQuery({
queryKey: projectKeys.taskCounts(),
queryFn: () => projectService.getTaskCountsForAllProjects(),
refetchInterval: false, // Don't poll, only refetch manually
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
}
// Fetch project features
export function useProjectFeatures(projectId: string | undefined) {
return useQuery({
queryKey: projectKeys.features(projectId!),
queryFn: () => projectService.getProjectFeatures(projectId!),
enabled: !!projectId,
staleTime: 30000, // Cache for 30 seconds
});
}
// Create project mutation
export function useCreateProject() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (projectData: CreateProjectRequest) =>
projectService.createProject(projectData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
showToast('Project created successfully!', 'success');
},
onError: (error) => {
console.error('Failed to create project:', error);
showToast('Failed to create project', 'error');
},
});
}
// Update project mutation (for pinning, etc.)
export function useUpdateProject() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: ({ projectId, updates }: { projectId: string; updates: UpdateProjectRequest }) =>
projectService.updateProject(projectId, updates),
onMutate: async ({ projectId, updates }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
// Snapshot the previous value
const previousProjects = queryClient.getQueryData(projectKeys.lists());
// Optimistically update
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
if (!old) return old;
// If pinning a project, unpin all others first
if (updates.pinned === true) {
return old.map(p => ({
...p,
pinned: p.id === projectId ? true : false
}));
}
return old.map(p =>
p.id === projectId ? { ...p, ...updates } : p
);
});
return { previousProjects };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousProjects) {
queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
}
showToast('Failed to update project', 'error');
},
onSuccess: (data, variables) => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
if (variables.updates.pinned !== undefined) {
const message = variables.updates.pinned
? `Pinned "${data.title}" as default project`
: `Removed "${data.title}" from default selection`;
showToast(message, 'info');
}
},
});
}
// Delete project mutation
export function useDeleteProject() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (projectId: string) => projectService.deleteProject(projectId),
onSuccess: (_, projectId) => {
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
// Also invalidate the specific project's data
queryClient.removeQueries({ queryKey: projectKeys.detail(projectId) });
},
onError: (error) => {
console.error('Failed to delete project:', error);
showToast('Failed to delete project', 'error');
},
});
}
// Create task mutation
export function useCreateTask() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (taskData: any) => projectService.createTask(taskData),
onSuccess: (data, variables) => {
// Invalidate tasks for the project
queryClient.invalidateQueries({ queryKey: projectKeys.tasks(variables.project_id) });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
showToast('Task created successfully', 'success');
},
onError: (error) => {
console.error('Failed to create task:', error);
showToast('Failed to create task', 'error');
},
});
}
// Update task mutation with optimistic updates
export function useUpdateTask(projectId: string) {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: ({ taskId, updates }: { taskId: string; updates: any }) =>
projectService.updateTask(taskId, updates),
onMutate: async ({ taskId, updates }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: projectKeys.tasks(projectId) });
// Snapshot the previous value
const previousTasks = queryClient.getQueryData(projectKeys.tasks(projectId));
// Optimistically update
queryClient.setQueryData(projectKeys.tasks(projectId), (old: any[] | undefined) => {
if (!old) return old;
return old.map((task: any) =>
task.id === taskId ? { ...task, ...updates } : task
);
});
return { previousTasks };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousTasks) {
queryClient.setQueryData(projectKeys.tasks(projectId), context.previousTasks);
}
showToast('Failed to update task', 'error');
// Refetch on error to ensure consistency
queryClient.invalidateQueries({ queryKey: projectKeys.tasks(projectId) });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
},
onSuccess: () => {
// Don't refetch on success for task_order updates - trust optimistic update
// Only invalidate task counts
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
},
});
}
// Delete task mutation
export function useDeleteTask(projectId: string) {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (taskId: string) => projectService.deleteTask(taskId),
onMutate: async (taskId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: projectKeys.tasks(projectId) });
// Snapshot the previous value
const previousTasks = queryClient.getQueryData(projectKeys.tasks(projectId));
// Optimistically remove the task
queryClient.setQueryData(projectKeys.tasks(projectId), (old: any[] | undefined) => {
if (!old) return old;
return old.filter((task: any) => task.id !== taskId);
});
return { previousTasks };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousTasks) {
queryClient.setQueryData(projectKeys.tasks(projectId), context.previousTasks);
}
showToast('Failed to delete task', 'error');
},
onSuccess: () => {
showToast('Task deleted successfully', 'success');
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: projectKeys.tasks(projectId) });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
},
});
}

View File

@@ -1,12 +1,17 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useToast } from "../contexts/ToastContext";
import { motion } from "framer-motion";
import { useStaggeredEntrance } from "../hooks/useStaggeredEntrance";
import { useProjectPolling, useTaskPolling } from "../hooks/usePolling";
import { useDatabaseMutation } from "../hooks/useDatabaseMutation";
import { useProjectMutation } from "../hooks/useProjectMutation";
import { debounce } from "../utils/debounce";
import {
useProjects,
useTaskCounts,
useCreateProject,
useUpdateProject,
useDeleteProject,
projectKeys,
} from "../hooks/useProjectQueries";
import {
Tabs,
TabsList,
@@ -29,13 +34,9 @@ import {
Clipboard,
} from "lucide-react";
// Import our service layer and types
import { projectService } from "../services/projectService";
import type { Project, CreateProjectRequest } from "../types/project";
import type { Task } from "../components/project-tasks/TaskTableView";
import { DeleteConfirmModal } from "../components/common/DeleteConfirmModal";
interface ProjectPageProps {
className?: string;
"data-id"?: string;
@@ -47,275 +48,56 @@ function ProjectPage({
}: ProjectPageProps) {
const { projectId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
// State management for real data
// State management for selected project and UI
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [projectTaskCounts, setProjectTaskCounts] = useState<
Record<string, { todo: number; doing: number; done: number }>
>({});
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
const [tasksError, setTasksError] = useState<string | null>(null);
const [isSwitchingProject, setIsSwitchingProject] = useState(false);
// Task counts cache with 5-minute TTL
const taskCountsCache = useRef<{
data: Record<string, { todo: number; doing: number; done: number }>;
timestamp: number;
} | null>(null);
// UI state
const [activeTab, setActiveTab] = useState("tasks");
const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false);
// New project form state
const [newProjectForm, setNewProjectForm] = useState({
title: "",
description: "",
});
// State for delete confirmation modal
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<{
id: string;
title: string;
} | null>(null);
// State for copy feedback
const [copiedProjectId, setCopiedProjectId] = useState<string | null>(null);
const { showToast } = useToast();
// Polling hooks for real-time updates
const {
data: projectsData,
isLoading: isLoadingProjects,
error: projectsError,
refetch: refetchProjects,
} = useProjectPolling({
onError: (error) => {
console.error("Failed to load projects:", error);
showToast("Failed to load projects. Please try again.", "error");
},
});
// React Query hooks
const { data: projects = [], isLoading: isLoadingProjects, error: projectsError } = useProjects();
const { data: taskCounts = {}, refetch: refetchTaskCounts } = useTaskCounts();
// Derive projects array from polling data - ensure it's always an array
const projects = Array.isArray(projectsData) ? projectsData : (projectsData?.projects || []);
// Poll tasks for selected project
const {
data: tasksData,
isLoading: isPollingTasks,
} = useTaskPolling(selectedProject?.id || "", {
enabled: !!selectedProject && !isSwitchingProject,
onError: (error) => {
console.error("Failed to load tasks:", error);
setTasksError(error.message);
},
});
// Mutations
const createProjectMutation = useCreateProject();
const updateProjectMutation = useUpdateProject();
const deleteProjectMutation = useDeleteProject();
// Project mutations
const deleteProjectMutation = useProjectMutation(
null,
async (projectId: string) => {
return await projectService.deleteProject(projectId);
},
{
successMessage: projectToDelete
? `Project "${projectToDelete.title}" deleted successfully`
: "Project deleted successfully",
onSuccess: () => {
if (selectedProject?.id === projectToDelete?.id) {
setSelectedProject(null);
// Navigate back to projects without a specific ID
navigate('/projects', { replace: true });
}
setShowDeleteConfirm(false);
setProjectToDelete(null);
},
onError: (error) => {
console.error("Failed to delete project:", error);
},
},
);
const togglePinMutation = useProjectMutation(
null,
async ({ projectId, pinned }: { projectId: string; pinned: boolean }) => {
return await projectService.updateProject(projectId, { pinned });
},
{
onSuccess: (data, variables) => {
const message = variables.pinned
? `Pinned "${data.title}" as default project`
: `Removed "${data.title}" from default selection`;
showToast(message, "info");
},
onError: (error) => {
console.error("Failed to update project pin status:", error);
},
// Disable default success message since we have a custom one
successMessage: '',
},
);
const createProjectMutation = useDatabaseMutation(
async (projectData: CreateProjectRequest) => {
return await projectService.createProject(projectData);
},
{
successMessage: "Creating project...",
onSuccess: (response) => {
setNewProjectForm({ title: "", description: "" });
setIsNewProjectModalOpen(false);
// Polling will pick up the new project
showToast("Project created successfully!", "success");
refetchProjects();
},
onError: (error) => {
console.error("Failed to create project:", error);
},
},
);
// Direct API call for immediate task loading during project switch
const loadTasksForProject = useCallback(async (projectId: string) => {
try {
const taskData = await projectService.getTasksByProject(projectId);
// Use the same formatting logic as polling onSuccess callback
const uiTasks: Task[] = taskData.map((task: any) => ({
id: task.id,
title: task.title,
description: task.description,
status: (task.status || "todo") as Task["status"],
assignee: {
name: (task.assignee || "User") as
| "User"
| "Archon"
| "AI IDE Agent",
avatar: "",
},
feature: task.feature || "General",
featureColor: task.featureColor || "#6366f1",
task_order: task.task_order || 0,
}));
setTasks(uiTasks);
} catch (error) {
console.error("Failed to load tasks:", error);
setTasksError(
error instanceof Error ? error.message : "Failed to load tasks",
);
}
}, []);
const handleProjectSelect = useCallback(async (project: Project) => {
// Early return if already selected
if (selectedProject?.id === project.id) return;
// Show loading state during project switch
setIsSwitchingProject(true);
setTasksError(null);
setTasks([]); // Clear stale tasks immediately to prevent wrong data showing
try {
setSelectedProject(project);
setActiveTab("tasks");
// Update URL to reflect selected project
navigate(`/projects/${project.id}`, { replace: true });
// Load tasks for the new project
await loadTasksForProject(project.id);
} catch (error) {
console.error('Failed to switch project:', error);
showToast('Failed to load project tasks', 'error');
} finally {
setIsSwitchingProject(false);
}
}, [selectedProject?.id, loadTasksForProject, showToast, navigate]);
// Load task counts for all projects using batch endpoint
const loadTaskCountsForAllProjects = useCallback(
async (projectIds: string[], force = false) => {
// Check cache first (5-minute TTL = 300000ms) unless force refresh is requested
const now = Date.now();
if (!force && taskCountsCache.current &&
(now - taskCountsCache.current.timestamp) < 300000) {
// Use cached data
const cachedCounts = taskCountsCache.current.data;
const filteredCounts: Record<string, { todo: number; doing: number; done: number }> = {};
projectIds.forEach((projectId) => {
if (cachedCounts[projectId]) {
filteredCounts[projectId] = cachedCounts[projectId];
} else {
filteredCounts[projectId] = { todo: 0, doing: 0, done: 0 };
}
});
setProjectTaskCounts(filteredCounts);
return;
}
try {
// Use single batch API call instead of N parallel calls
const counts = await projectService.getTaskCountsForAllProjects();
// Update cache
taskCountsCache.current = {
data: counts,
timestamp: now
};
// Filter to only requested projects and provide defaults for missing ones
const filteredCounts: Record<string, { todo: number; doing: number; done: number }> = {};
projectIds.forEach((projectId) => {
if (counts[projectId]) {
filteredCounts[projectId] = counts[projectId];
} else {
// Provide default counts if project not found
filteredCounts[projectId] = { todo: 0, doing: 0, done: 0 };
}
});
setProjectTaskCounts(filteredCounts);
} catch (error) {
console.error("Failed to load task counts:", error);
// Set all to 0 on complete failure
const emptyCounts: Record<string, { todo: number; doing: number; done: number }> = {};
projectIds.forEach((id) => {
emptyCounts[id] = { todo: 0, doing: 0, done: 0 };
});
setProjectTaskCounts(emptyCounts);
}
},
[],
);
// Create debounced version to avoid rapid API calls
const debouncedLoadTaskCounts = useMemo(
() => debounce((projectIds: string[], force = false) => {
loadTaskCountsForAllProjects(projectIds, force);
}, 1000),
[loadTaskCountsForAllProjects]
);
// Auto-select project based on URL or default to leftmost
useEffect(() => {
if (!projects?.length) return;
// Sort projects - single pinned project first, then alphabetically
const sortedProjects = [...projects].sort((a, b) => {
// With single pin, this is simpler: pinned project always comes first
// Sort projects - pinned first, then alphabetically
const sortedProjects = useMemo(() => {
return [...projects].sort((a, b) => {
if (a.pinned) return -1;
if (b.pinned) return 1;
return a.title.localeCompare(b.title);
});
}, [projects]);
// Load task counts for all projects (debounced, no force since this is initial load)
const projectIds = sortedProjects.map((p) => p.id);
debouncedLoadTaskCounts(projectIds, false);
// Handle project selection
const handleProjectSelect = useCallback((project: Project) => {
if (selectedProject?.id === project.id) return;
setSelectedProject(project);
setActiveTab("tasks");
navigate(`/projects/${project.id}`, { replace: true });
}, [selectedProject?.id, navigate]);
// Auto-select project based on URL or default to leftmost
useEffect(() => {
if (!sortedProjects.length) return;
// If we have a projectId in the URL, try to select that project
if (projectId) {
@@ -324,70 +106,22 @@ function ProjectPage({
handleProjectSelect(urlProject);
return;
}
// If URL project not found, fall through to default selection
}
// Select the leftmost (first) project if none is selected
if (!selectedProject && sortedProjects.length > 0) {
const leftmostProject = sortedProjects[0];
handleProjectSelect(leftmostProject);
handleProjectSelect(sortedProjects[0]);
}
}, [projects, selectedProject, handleProjectSelect, projectId]);
}, [sortedProjects, projectId, selectedProject, handleProjectSelect]);
// Update loading state based on polling
// Refetch task counts when project changes
useEffect(() => {
setIsLoadingTasks(isPollingTasks);
}, [isPollingTasks]);
// Refresh task counts when tasks update via polling AND keep UI in sync for selected project
useEffect(() => {
if (tasksData && selectedProject) {
const uiTasks: Task[] = tasksData.map((task: any) => ({
id: task.id,
title: task.title,
description: task.description,
status: (task.status || "todo") as Task["status"],
assignee: {
name: (task.assignee || "User") as "User" | "Archon" | "AI IDE Agent",
avatar: "",
},
feature: task.feature || "General",
featureColor: task.featureColor || "#6366f1",
task_order: task.task_order || 0,
}));
const changed =
tasks.length !== uiTasks.length ||
uiTasks.some((t) => {
const old = tasks.find((x) => x.id === t.id);
return (
!old ||
old.title !== t.title ||
old.description !== t.description ||
old.status !== t.status ||
old.assignee.name !== t.assignee.name ||
old.feature !== t.feature ||
old.task_order !== t.task_order
);
});
if (changed) {
setTasks(uiTasks);
const projectIds = projects.map((p) => p.id);
debouncedLoadTaskCounts(projectIds, true);
}
if (selectedProject) {
refetchTaskCounts();
}
}, [tasksData, projects, selectedProject?.id]);
// Manual refresh function using polling refetch
const loadProjects = async () => {
try {
await refetchProjects();
} catch (error) {
console.error("Failed to refresh projects:", error);
showToast("Failed to refresh projects. Please try again.", "error");
}
};
}, [selectedProject?.id, refetchTaskCounts]);
// Handle project operations
const handleDeleteProject = useCallback(
async (e: React.MouseEvent, projectId: string, projectTitle: string) => {
e.stopPropagation();
@@ -402,10 +136,20 @@ function ProjectPage({
try {
await deleteProjectMutation.mutateAsync(projectToDelete.id);
if (selectedProject?.id === projectToDelete.id) {
setSelectedProject(null);
navigate('/projects', { replace: true });
}
showToast(`Project "${projectToDelete.title}" deleted successfully`, 'success');
} catch (error) {
// Error handling is done by the mutation
// Error handled by mutation
} finally {
setShowDeleteConfirm(false);
setProjectToDelete(null);
}
}, [projectToDelete, deleteProjectMutation]);
}, [projectToDelete, deleteProjectMutation, selectedProject?.id, navigate, showToast]);
const cancelDeleteProject = useCallback(() => {
setShowDeleteConfirm(false);
@@ -416,27 +160,16 @@ function ProjectPage({
async (e: React.MouseEvent, project: Project) => {
e.stopPropagation();
const isPinning = !project.pinned;
try {
// Backend handles single-pin enforcement automatically
await togglePinMutation.mutateAsync({
await updateProjectMutation.mutateAsync({
projectId: project.id,
pinned: isPinning,
updates: { pinned: !project.pinned },
});
// Force immediate refresh of projects to update UI positioning
// This ensures the pinned project moves to leftmost position immediately
refetchProjects();
} catch (error) {
console.error("Failed to toggle pin:", error);
showToast("Failed to update pin status", "error");
// On error, still refresh to ensure UI is consistent with backend
refetchProjects();
// Error handled by mutation
}
},
[togglePinMutation, showToast, refetchProjects],
[updateProjectMutation],
);
const handleCreateProject = async () => {
@@ -452,14 +185,20 @@ function ProjectPage({
data: [],
};
await createProjectMutation.mutateAsync(projectData);
try {
await createProjectMutation.mutateAsync(projectData);
setNewProjectForm({ title: "", description: "" });
setIsNewProjectModalOpen(false);
} catch (error) {
// Error handled by mutation
}
};
// Add staggered entrance animations
const { isVisible, containerVariants, itemVariants, titleVariants } =
useStaggeredEntrance([1, 2, 3], 0.15);
return (
<motion.div
initial="hidden"
@@ -515,10 +254,10 @@ function ProjectPage({
<div className="text-center">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400 mb-4">
{projectsError.message || "Failed to load projects"}
{(projectsError as Error).message || "Failed to load projects"}
</p>
<Button
onClick={loadProjects}
onClick={() => queryClient.invalidateQueries({ queryKey: projectKeys.lists() })}
variant="primary"
accentColor="purple"
>
@@ -534,12 +273,12 @@ function ProjectPage({
<motion.div className="relative mb-10" variants={itemVariants}>
<div className="overflow-x-auto pb-4 scrollbar-thin">
<div className="flex gap-4 min-w-max">
{projects.map((project) => (
{sortedProjects.map((project) => (
<motion.div
key={project.id}
variants={itemVariants}
onClick={() => handleProjectSelect(project)}
className={`
key={project.id}
variants={itemVariants}
onClick={() => handleProjectSelect(project)}
className={`
relative p-4 rounded-xl backdrop-blur-md w-72 cursor-pointer overflow-hidden
${
project.pinned
@@ -564,213 +303,212 @@ function ProjectPage({
transition-all duration-300
${selectedProject?.id === project.id ? "translate-y-[-2px]" : "hover:translate-y-[-2px]"}
`}
>
{/* Subtle aurora glow effect for selected card */}
{selectedProject?.id === project.id && (
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-30 dark:opacity-40">
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(168,85,247,0.8)_0%,rgba(147,51,234,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
</div>
)}
>
{/* Subtle aurora glow effect for selected card */}
{selectedProject?.id === project.id && (
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-30 dark:opacity-40">
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(168,85,247,0.8)_0%,rgba(147,51,234,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
</div>
)}
<div className="relative z-10">
<div className="flex items-center justify-center mb-4 px-2">
<h3
className={`font-medium text-center leading-tight line-clamp-2 transition-all duration-300 ${
<div className="relative z-10">
<div className="flex items-center justify-center mb-4 px-2">
<h3
className={`font-medium text-center leading-tight line-clamp-2 transition-all duration-300 ${
selectedProject?.id === project.id
? "text-gray-900 dark:text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]"
: "text-gray-500 dark:text-gray-400"
}`}
>
{project.title}
</h3>
</div>
<div className="flex items-stretch gap-2 w-full">
{/* Task count pills */}
{/* Todo pill */}
<div className="relative flex-1">
<div
className={`absolute inset-0 bg-pink-600 rounded-full blur-md ${selectedProject?.id === project.id ? "opacity-30 dark:opacity-75" : "opacity-0"}`}
></div>
<div
className={`relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300 ${
selectedProject?.id === project.id
? "text-gray-900 dark:text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]"
: "text-gray-500 dark:text-gray-400"
? "bg-white/70 dark:bg-zinc-900/90 border-pink-300 dark:border-pink-500/50 dark:shadow-[0_0_10px_rgba(236,72,153,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(236,72,153,0.7)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50"
}`}
>
{project.title}
</h3>
</div>
<div className="flex items-stretch gap-2 w-full">
{/* Neon pill boxes for task counts */}
{/* Todo pill */}
<div className="relative flex-1">
<div
className={`absolute inset-0 bg-pink-600 rounded-full blur-md ${selectedProject?.id === project.id ? "opacity-30 dark:opacity-75" : "opacity-0"}`}
></div>
<div
className={`relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300 ${
selectedProject?.id === project.id
? "bg-white/70 dark:bg-zinc-900/90 border-pink-300 dark:border-pink-500/50 dark:shadow-[0_0_10px_rgba(236,72,153,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(236,72,153,0.7)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50"
}`}
>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<ListTodo
className={`w-4 h-4 ${selectedProject?.id === project.id ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600"}`}
/>
<span
className={`text-[8px] font-medium ${selectedProject?.id === project.id ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600"}`}
>
ToDo
</span>
</div>
<div
className={`flex-1 flex items-center justify-center border-l ${selectedProject?.id === project.id ? "border-pink-300 dark:border-pink-500/30" : "border-gray-300/50 dark:border-gray-700/50"}`}
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<ListTodo
className={`w-4 h-4 ${selectedProject?.id === project.id ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600"}`}
/>
<span
className={`text-[8px] font-medium ${selectedProject?.id === project.id ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600"}`}
>
<span
className={`text-lg font-bold ${selectedProject?.id === project.id ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600"}`}
>
{projectTaskCounts[project.id]?.todo || 0}
</span>
</div>
ToDo
</span>
</div>
</div>
{/* Doing pill */}
<div className="relative flex-1">
<div
className={`absolute inset-0 bg-blue-600 rounded-full blur-md ${selectedProject?.id === project.id ? "opacity-30 dark:opacity-75" : "opacity-0"}`}
></div>
<div
className={`relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300 ${
selectedProject?.id === project.id
? "bg-white/70 dark:bg-zinc-900/90 border-blue-300 dark:border-blue-500/50 dark:shadow-[0_0_10px_rgba(59,130,246,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(59,130,246,0.7)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50"
}`}
className={`flex-1 flex items-center justify-center border-l ${selectedProject?.id === project.id ? "border-pink-300 dark:border-pink-500/30" : "border-gray-300/50 dark:border-gray-700/50"}`}
>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<Activity
className={`w-4 h-4 ${selectedProject?.id === project.id ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600"}`}
/>
<span
className={`text-[8px] font-medium ${selectedProject?.id === project.id ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600"}`}
>
Doing
</span>
</div>
<div
className={`flex-1 flex items-center justify-center border-l ${selectedProject?.id === project.id ? "border-blue-300 dark:border-blue-500/30" : "border-gray-300/50 dark:border-gray-700/50"}`}
<span
className={`text-lg font-bold ${selectedProject?.id === project.id ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600"}`}
>
<span
className={`text-lg font-bold ${selectedProject?.id === project.id ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600"}`}
>
{projectTaskCounts[project.id]?.doing || 0}
</span>
</div>
</div>
</div>
{/* Done pill */}
<div className="relative flex-1">
<div
className={`absolute inset-0 bg-green-600 rounded-full blur-md ${selectedProject?.id === project.id ? "opacity-30 dark:opacity-75" : "opacity-0"}`}
></div>
<div
className={`relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300 ${
selectedProject?.id === project.id
? "bg-white/70 dark:bg-zinc-900/90 border-green-300 dark:border-green-500/50 dark:shadow-[0_0_10px_rgba(34,197,94,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(34,197,94,0.7)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50"
}`}
>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<CheckCircle2
className={`w-4 h-4 ${selectedProject?.id === project.id ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600"}`}
/>
<span
className={`text-[8px] font-medium ${selectedProject?.id === project.id ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600"}`}
>
Done
</span>
</div>
<div
className={`flex-1 flex items-center justify-center border-l ${selectedProject?.id === project.id ? "border-green-300 dark:border-green-500/30" : "border-gray-300/50 dark:border-gray-700/50"}`}
>
<span
className={`text-lg font-bold ${selectedProject?.id === project.id ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600"}`}
>
{projectTaskCounts[project.id]?.done || 0}
</span>
</div>
{taskCounts[project.id]?.todo || 0}
</span>
</div>
</div>
</div>
{/* Action buttons - At bottom of card */}
<div className="mt-3 pt-3 border-t border-gray-200/50 dark:border-gray-700/30 flex items-center justify-between gap-2">
{/* Pin button */}
<button
onClick={(e) => handleTogglePin(e, project)}
disabled={togglePinMutation.isPending}
className={`p-1.5 rounded-full ${
togglePinMutation.isPending
? "bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800/50 dark:text-gray-500"
: project.pinned === true
? "bg-purple-100 text-purple-700 dark:bg-purple-700/30 dark:text-purple-400 hover:bg-purple-200 hover:text-purple-800 dark:hover:bg-purple-800/50 dark:hover:text-purple-300"
: "bg-gray-100 text-gray-500 dark:bg-gray-800/70 dark:text-gray-400 hover:bg-purple-200 hover:text-purple-800 dark:hover:bg-purple-800/50 dark:hover:text-purple-300"
} transition-colors`}
title={
togglePinMutation.isPending
? "Updating pin status..."
: project.pinned === true
? "Unpin project"
: "Pin project"
}
aria-label={
togglePinMutation.isPending
? "Updating pin status..."
: project.pinned === true
? "Unpin project"
: "Pin project"
}
data-pinned={project.pinned}
{/* Doing pill */}
<div className="relative flex-1">
<div
className={`absolute inset-0 bg-blue-600 rounded-full blur-md ${selectedProject?.id === project.id ? "opacity-30 dark:opacity-75" : "opacity-0"}`}
></div>
<div
className={`relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300 ${
selectedProject?.id === project.id
? "bg-white/70 dark:bg-zinc-900/90 border-blue-300 dark:border-blue-500/50 dark:shadow-[0_0_10px_rgba(59,130,246,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(59,130,246,0.7)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50"
}`}
>
<Pin
className="w-3.5 h-3.5"
fill={
project.pinned === true ? "currentColor" : "none"
}
/>
</button>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<Activity
className={`w-4 h-4 ${selectedProject?.id === project.id ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600"}`}
/>
<span
className={`text-[8px] font-medium ${selectedProject?.id === project.id ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600"}`}
>
Doing
</span>
</div>
<div
className={`flex-1 flex items-center justify-center border-l ${selectedProject?.id === project.id ? "border-blue-300 dark:border-blue-500/30" : "border-gray-300/50 dark:border-gray-700/50"}`}
>
<span
className={`text-lg font-bold ${selectedProject?.id === project.id ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600"}`}
>
{taskCounts[project.id]?.doing || 0}
</span>
</div>
</div>
</div>
{/* Copy Project ID Button */}
<button
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(project.id);
showToast(
"Project ID copied to clipboard",
"success",
);
// Visual feedback with React state
setCopiedProjectId(project.id);
setTimeout(() => {
setCopiedProjectId(null);
}, 2000);
}}
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"
{/* Done pill */}
<div className="relative flex-1">
<div
className={`absolute inset-0 bg-green-600 rounded-full blur-md ${selectedProject?.id === project.id ? "opacity-30 dark:opacity-75" : "opacity-0"}`}
></div>
<div
className={`relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300 ${
selectedProject?.id === project.id
? "bg-white/70 dark:bg-zinc-900/90 border-green-300 dark:border-green-500/50 dark:shadow-[0_0_10px_rgba(34,197,94,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(34,197,94,0.7)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50"
}`}
>
{copiedProjectId === project.id ? (
<>
<CheckCircle2 className="w-3 h-3" />
<span>Copied!</span>
</>
) : (
<>
<Clipboard className="w-3 h-3" />
<span>Copy ID</span>
</>
)}
</button>
{/* Delete button */}
<button
onClick={(e) =>
handleDeleteProject(e, project.id, project.title)
}
className="p-1.5 rounded-full bg-gray-100 text-gray-500 hover:bg-red-100 hover:text-red-600 dark:bg-gray-800/70 dark:text-gray-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors"
title="Delete project"
aria-label="Delete project"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<CheckCircle2
className={`w-4 h-4 ${selectedProject?.id === project.id ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600"}`}
/>
<span
className={`text-[8px] font-medium ${selectedProject?.id === project.id ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600"}`}
>
Done
</span>
</div>
<div
className={`flex-1 flex items-center justify-center border-l ${selectedProject?.id === project.id ? "border-green-300 dark:border-green-500/30" : "border-gray-300/50 dark:border-gray-700/50"}`}
>
<span
className={`text-lg font-bold ${selectedProject?.id === project.id ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600"}`}
>
{taskCounts[project.id]?.done || 0}
</span>
</div>
</div>
</div>
</div>
</motion.div>
{/* Action buttons */}
<div className="mt-3 pt-3 border-t border-gray-200/50 dark:border-gray-700/30 flex items-center justify-between gap-2">
{/* Pin button */}
<button
onClick={(e) => handleTogglePin(e, project)}
disabled={updateProjectMutation.isPending}
className={`p-1.5 rounded-full ${
updateProjectMutation.isPending
? "bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800/50 dark:text-gray-500"
: project.pinned === true
? "bg-purple-100 text-purple-700 dark:bg-purple-700/30 dark:text-purple-400 hover:bg-purple-200 hover:text-purple-800 dark:hover:bg-purple-800/50 dark:hover:text-purple-300"
: "bg-gray-100 text-gray-500 dark:bg-gray-800/70 dark:text-gray-400 hover:bg-purple-200 hover:text-purple-800 dark:hover:bg-purple-800/50 dark:hover:text-purple-300"
} transition-colors`}
title={
updateProjectMutation.isPending
? "Updating pin status..."
: project.pinned === true
? "Unpin project"
: "Pin project"
}
aria-label={
updateProjectMutation.isPending
? "Updating pin status..."
: project.pinned === true
? "Unpin project"
: "Pin project"
}
data-pinned={project.pinned}
>
<Pin
className="w-3.5 h-3.5"
fill={
project.pinned === true ? "currentColor" : "none"
}
/>
</button>
{/* Copy Project ID Button */}
<button
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(project.id);
showToast(
"Project ID copied to clipboard",
"success",
);
setCopiedProjectId(project.id);
setTimeout(() => {
setCopiedProjectId(null);
}, 2000);
}}
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"
>
{copiedProjectId === project.id ? (
<>
<CheckCircle2 className="w-3 h-3" />
<span>Copied!</span>
</>
) : (
<>
<Clipboard className="w-3 h-3" />
<span>Copy ID</span>
</>
)}
</button>
{/* Delete button */}
<button
onClick={(e) =>
handleDeleteProject(e, project.id, project.title)
}
className="p-1.5 rounded-full bg-gray-100 text-gray-500 hover:bg-red-100 hover:text-red-600 dark:bg-gray-800/70 dark:text-gray-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors"
title="Delete project"
aria-label="Delete project"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</motion.div>
))}
</div>
</div>
@@ -780,20 +518,6 @@ function ProjectPage({
{/* Project Details Section */}
{selectedProject && (
<motion.div variants={itemVariants} className="relative">
{/* Loading overlay when switching projects */}
{isSwitchingProject && (
<div className="absolute inset-0 bg-white/50 dark:bg-black/50 backdrop-blur-sm z-40 flex items-center justify-center rounded-lg">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-xl">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500"></div>
<span className="text-gray-700 dark:text-gray-300 font-medium">
Loading project...
</span>
</div>
</div>
</div>
)}
<Tabs
defaultValue="tasks"
value={activeTab}
@@ -817,54 +541,16 @@ function ProjectPage({
</TabsTrigger>
</TabsList>
{/* Tab content without AnimatePresence to prevent unmounting */}
{/* Tab content */}
<div>
{activeTab === "docs" && (
<TabsContent value="docs" className="mt-0">
<DocsTab tasks={tasks} project={selectedProject} />
<DocsTab project={selectedProject} />
</TabsContent>
)}
{activeTab === "tasks" && (
<TabsContent value="tasks" className="mt-0">
{isLoadingTasks ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Loader2 className="w-6 h-6 text-orange-500 mx-auto mb-4 animate-spin" />
<p className="text-gray-600 dark:text-gray-400">
Loading tasks...
</p>
</div>
</div>
) : tasksError ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<AlertCircle className="w-6 h-6 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400 mb-4">
{tasksError}
</p>
<Button
onClick={() =>
loadTasksForProject(selectedProject.id)
}
variant="primary"
accentColor="purple"
>
Retry
</Button>
</div>
</div>
) : (
<TasksTab
initialTasks={tasks}
onTasksChange={(updatedTasks) => {
setTasks(updatedTasks);
// Force refresh task counts for all projects when tasks change
const projectIds = projects.map((p) => p.id);
debouncedLoadTaskCounts(projectIds, true);
}}
projectId={selectedProject.id}
/>
)}
<TasksTab projectId={selectedProject.id} />
</TabsContent>
)}
</div>
@@ -984,4 +670,4 @@ function ProjectPage({
);
}
export { ProjectPage };
export { ProjectPage };

View File

@@ -131,9 +131,11 @@ export const projectService = {
async listProjects(): Promise<Project[]> {
try {
console.log('[PROJECT SERVICE] Fetching projects from API');
const projects = await callAPI<Project[]>('/api/projects');
console.log('[PROJECT SERVICE] Raw API response:', projects);
console.log('[PROJECT SERVICE] Raw API response length:', projects.length);
const response = await callAPI<{ projects: Project[] }>('/api/projects');
console.log('[PROJECT SERVICE] Raw API response:', response);
const projects = response.projects || [];
console.log('[PROJECT SERVICE] Projects array length:', projects.length);
// Debug raw pinned values
projects.forEach((p: any) => {

View File

@@ -157,6 +157,7 @@ services:
- HOST=${HOST:-localhost}
- PROD=${PROD:-false}
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-}
- VITE_SHOW_DEVTOOLS=${VITE_SHOW_DEVTOOLS:-false}
networks:
- app-network
healthcheck:

297
report.md Normal file
View File

@@ -0,0 +1,297 @@
# Archon Data Fetching Architecture Analysis
## Executive Summary
After conducting a deep analysis of Archon's current data fetching implementation, I've found a **mixed architecture** where some components have been refactored to use TanStack Query while others still use traditional polling. The backend has a sophisticated HTTP polling system with ETag optimization. This report analyzes whether continuing with TanStack Query is the right path forward.
**Key Findings:**
-**TanStack Query is the right choice** for most use cases
- ✅ Backend HTTP polling with ETags is well-architected and performant
- ⚠️ **Inconsistent implementation** - mixed patterns causing confusion
- ❌ WebSocket would add complexity without significant benefits for current use cases
## Current Architecture Analysis
### Backend: HTTP Polling with ETag Optimization
The backend implements a sophisticated polling system:
**Progress API (`/api/progress/{operation_id}`):**
```python
# ETag support for 70% bandwidth reduction via 304 Not Modified
current_etag = generate_etag(etag_data)
if check_etag(if_none_match, current_etag):
response.status_code = http_status.HTTP_304_NOT_MODIFIED
return None
# Smart polling hints
if operation.get("status") == "running":
response.headers["X-Poll-Interval"] = "1000" # Poll every 1s
else:
response.headers["X-Poll-Interval"] = "0" # Stop polling
```
**ProgressTracker (In-Memory State):**
- Thread-safe class-level storage: `_progress_states: dict[str, dict[str, Any]]`
- Prevents progress regression: Never allows backwards progress updates
- Automatic cleanup and duration calculation
- Rich status tracking with logs and metadata
**ETag Implementation:**
- MD5 hash of stable JSON data (excluding timestamps)
- 304 Not Modified responses when data unchanged
- ~70% bandwidth reduction in practice
### Frontend: Mixed Implementation Patterns
#### ✅ **TanStack Query Implementation** (New Components)
**Query Key Factories:**
```typescript
export const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
tasks: (projectId: string) => [...projectKeys.detail(projectId), 'tasks'] as const,
};
```
**Optimistic Updates:**
```typescript
onMutate: async ({ taskId, updates }) => {
await queryClient.cancelQueries({ queryKey: projectKeys.tasks(projectId) });
const previousTasks = queryClient.getQueryData(projectKeys.tasks(projectId));
queryClient.setQueryData(projectKeys.tasks(projectId), (old: any[]) => {
return old.map((task: any) =>
task.id === taskId ? { ...task, ...updates } : task
);
});
return { previousTasks };
},
```
**Progress Polling with Smart Completion:**
```typescript
export function useCrawlProgressPolling(progressId: string | null) {
const [isComplete, setIsComplete] = useState(false);
const query = useQuery({
queryKey: crawlKeys.progress(progressId!),
queryFn: async () => {
const response = await fetch(`/api/progress/${progressId}`);
return response.json();
},
enabled: !!progressId && !isComplete,
refetchInterval: 1000, // 1 second polling
retry: false,
staleTime: 0,
});
// Auto-stop polling when complete
useEffect(() => {
const status = query.data?.status;
if (['completed', 'failed', 'error', 'cancelled'].includes(status)) {
setIsComplete(true);
}
}, [query.data?.status]);
return { ...query, isComplete };
}
```
#### ❌ **Legacy Implementation** (KnowledgeBasePage)
Still uses manual useState with custom polling:
```typescript
const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeItem[]>([]);
const [loading, setLoading] = useState(true);
const [progressItems, setProgressItems] = useState<CrawlProgressData[]>([]);
// Manual API calls
const loadKnowledgeItems = async () => {
try {
setLoading(true);
const response = await knowledgeBaseService.getKnowledgeItems();
setKnowledgeItems(response.items);
} catch (error) {
// Manual error handling
} finally {
setLoading(false);
}
};
```
#### ⚠️ **Remaining Traditional Hooks**
`useMigrationStatus.ts` - Uses setInterval polling:
```typescript
useEffect(() => {
const checkMigrationStatus = async () => {
const response = await fetch('/api/health');
// Manual state updates
};
const interval = setInterval(checkMigrationStatus, 30000);
return () => clearInterval(interval);
}, []);
```
## TanStack Query vs WebSocket Analysis
### TanStack Query Advantages ✅
1. **Perfect for Archon's Use Cases:**
- CRUD operations on projects, tasks, knowledge items
- Progress polling with natural start/stop lifecycle
- Background refetching for stale data
- Optimistic updates for immediate UI feedback
2. **Built-in Features:**
- Automatic background refetching
- Request deduplication
- Error retry with exponential backoff
- Cache invalidation strategies
- Loading and error states
- Optimistic updates with rollback
3. **Performance Benefits:**
- Client-side caching reduces server load
- ETags work perfectly with query invalidation
- Smart refetch intervals (active/background)
- Automatic garbage collection
4. **Developer Experience:**
- Declarative data dependencies
- Less boilerplate than manual useState
- Excellent DevTools for debugging
- Type-safe with TypeScript
### WebSocket Analysis ❌
**Current Use Cases Don't Need Real-time:**
- Progress updates: 1-2 second delay acceptable
- Project/task updates: Not truly collaborative
- Knowledge base changes: Batch-oriented operations
**WebSocket Downsides:**
- Connection management complexity
- Reconnection logic needed
- Scaling challenges (sticky sessions)
- No HTTP caching benefits
- Additional security considerations
- Browser connection limits (6 per domain)
**When WebSockets Make Sense:**
- Real-time collaboration (multiple users editing same document)
- Live chat/notifications
- Live data feeds (stock prices, sports scores)
- Gaming applications
### Performance Comparison
| Metric | HTTP Polling + TanStack | WebSocket |
|--------|-------------------------|-----------|
| Initial Connection | HTTP request (~10-50ms) | WebSocket handshake (~100-200ms) |
| Update Latency | 500-2000ms (configurable) | ~10-100ms |
| Bandwidth (unchanged data) | ~100 bytes (304 response) | ~50 bytes (heartbeat) |
| Bandwidth (changed data) | Full payload + headers | Full payload |
| Server Memory | Stateless (per request) | Connection state per client |
| Horizontal Scaling | Easy (stateless) | Complex (sticky sessions) |
| Browser Limits | ~6 concurrent per domain | ~255 concurrent total |
| Error Recovery | Automatic retry | Manual reconnection logic |
## Current Issues & Recommendations
### 🔴 **Critical Issues**
1. **Inconsistent Patterns:** Mix of TanStack Query, manual useState, and setInterval polling
2. **KnowledgeBasePage Not Migrated:** Still using 795 lines of manual state management
3. **Prop Drilling:** Components receiving 5+ callback props instead of using mutations
### 🟡 **Performance Issues**
1. **Multiple Polling Intervals:** Different components polling at different rates
2. **No Request Deduplication:** Manual implementations don't dedupe requests
3. **Cache Misses:** Manual state doesn't benefit from cross-component caching
### ✅ **Recommended Solution: Complete TanStack Query Migration**
#### Phase 1: Complete Current Migration
```typescript
// Migrate KnowledgeBasePage to use:
const { data: knowledgeItems, isLoading, error } = useKnowledgeItems();
const { data: progressItems, addProgressItem, removeProgressItem } = useCrawlProgressManager();
const deleteMutation = useDeleteKnowledgeItem();
```
#### Phase 2: Optimize Query Configuration
```typescript
// Global query client optimization
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s for most data
gcTime: 5 * 60_000, // 5min cache retention
retry: (failureCount, error) => {
// Smart retry logic
if (error.status === 404) return false;
return failureCount < 3;
},
},
},
});
```
#### Phase 3: Advanced Patterns
```typescript
// Progress polling with exponential backoff
const useSmartProgressPolling = (progressId: string) => {
const [pollInterval, setPollInterval] = useState(1000);
return useQuery({
queryKey: ['progress', progressId],
queryFn: () => fetchProgress(progressId),
refetchInterval: (data) => {
if (data?.status === 'completed') return false;
// Exponential backoff for long-running operations
const runtime = Date.now() - data?.start_time;
if (runtime > 60_000) return 5000; // 5s after 1 minute
if (runtime > 300_000) return 10_000; // 10s after 5 minutes
return 1000; // 1s for first minute
},
});
};
```
### 🎯 **Migration Strategy**
1. **Keep Backend As-Is:** HTTP polling + ETags is working well
2. **Complete TanStack Migration:** Migrate remaining components
3. **Standardize Query Keys:** Consistent factory pattern
4. **Optimize Poll Intervals:** Smart intervals based on data type
5. **Add Error Boundaries:** Better error handling at app level
### 🚀 **Expected Benefits**
- **50% Less Component Code:** Remove manual useState boilerplate
- **Better UX:** Optimistic updates, background refetching, error retry
- **Improved Performance:** Request deduplication, smart caching
- **Easier Debugging:** TanStack DevTools visibility
- **Type Safety:** Better TypeScript integration
## Conclusion
**✅ Continue with TanStack Query migration** - it's the right architectural choice for Archon's use cases. The backend HTTP polling system is well-designed and doesn't need changes. Focus on:
1. **Completing the migration** of remaining components
2. **Standardizing patterns** across all data fetching
3. **Optimizing query configurations** for better performance
WebSocket would add complexity without meaningful benefits for current requirements. The HTTP polling + TanStack Query combination provides the right balance of performance, developer experience, and maintainability.
---
*Analysis completed on 2025-01-03*
*Total files analyzed: 15+ backend files, 9 frontend hooks, 5 major components*