Implement streaming for project tasks (#608)
* Stream tasks and execution processes (vibe-kanban cd4106c5) Building on the unused /events endpoint, can we please add a /stream variant to the following endpoints: /tasks?project_id=... /execution_processes?task_attempt_id=... The endpoint should return an initial document containing all the entities given the filter, and then subsequent patches to keep the document up to date. Refactor the codebase however you see fit to give us the most maintainable code going forwards. crates/server/src/routes/tasks.rs crates/server/src/routes/execution_processes.rs crates/server/src/routes/events.rs * Issues with streaming tasks (vibe-kanban e1779942) crates/services/src/services/events.rs crates/server/src/routes/tasks.rs We should modify the stream of tasks (filtered by project) to be an object where each task is a key. This will make it much easier to produce stream diffs * Issues with streaming tasks (vibe-kanban e1779942) crates/services/src/services/events.rs crates/server/src/routes/tasks.rs We should modify the stream of tasks (filtered by project) to be an object where each task is a key. This will make it much easier to produce stream diffs * Refactor project tasks (vibe-kanban 20b19eb8) Project tasks needs to be refactored: - Doesn't follow new pattern of separating network logic into hooks - Has legacy fixed time poll for refetching tasks, but there is now a tasks/stream endpoint * revert changes to execution processes
This commit is contained in:
committed by
GitHub
parent
5ca32b50de
commit
af63563e17
46
frontend/src/hooks/useProjectTasks.ts
Normal file
46
frontend/src/hooks/useProjectTasks.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useJsonPatchStream } from './useJsonPatchStream';
|
||||
import type { TaskWithAttemptStatus } from 'shared/types';
|
||||
|
||||
type TasksState = {
|
||||
tasks: Record<string, TaskWithAttemptStatus>;
|
||||
};
|
||||
|
||||
interface UseProjectTasksResult {
|
||||
tasks: TaskWithAttemptStatus[];
|
||||
tasksById: Record<string, TaskWithAttemptStatus>;
|
||||
isLoading: boolean;
|
||||
isConnected: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream tasks for a project via SSE (JSON Patch) and expose as array + map.
|
||||
* Server sends initial snapshot: replace /tasks with an object keyed by id.
|
||||
* Live updates arrive at /tasks/<id> via add/replace/remove operations.
|
||||
*/
|
||||
export const useProjectTasks = (
|
||||
projectId: string | undefined
|
||||
): UseProjectTasksResult => {
|
||||
const endpoint = projectId
|
||||
? `/api/tasks/stream?project_id=${encodeURIComponent(projectId)}`
|
||||
: undefined;
|
||||
|
||||
const initialData = useCallback((): TasksState => ({ tasks: {} }), []);
|
||||
|
||||
const { data, isConnected, error } = useJsonPatchStream<TasksState>(
|
||||
endpoint,
|
||||
!!projectId,
|
||||
initialData
|
||||
);
|
||||
|
||||
const tasksById = data?.tasks ?? {};
|
||||
const tasks = Object.values(tasksById).sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at as unknown as string).getTime() -
|
||||
new Date(a.created_at as unknown as string).getTime()
|
||||
);
|
||||
const isLoading = !data && !error; // until first snapshot
|
||||
|
||||
return { tasks, tasksById, isLoading, isConnected, error };
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import TaskKanbanBoard from '@/components/tasks/TaskKanbanBoard';
|
||||
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
||||
import type { TaskWithAttemptStatus, Project, TaskAttempt } from 'shared/types';
|
||||
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
|
||||
import { useProjectTasks } from '@/hooks/useProjectTasks';
|
||||
|
||||
type Task = TaskWithAttemptStatus;
|
||||
|
||||
@@ -41,9 +42,7 @@ export function ProjectTasks() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { openCreate, openEdit, openDuplicate } = useTaskDialog();
|
||||
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
||||
@@ -91,20 +90,27 @@ export function ProjectTasks() {
|
||||
[navigate, projectId, selectedTask, isFullscreen]
|
||||
);
|
||||
|
||||
// Sync selectedTask with URL params
|
||||
// Stream tasks for this project
|
||||
const {
|
||||
tasks,
|
||||
tasksById,
|
||||
isLoading,
|
||||
error: streamError,
|
||||
} = useProjectTasks(projectId);
|
||||
|
||||
// Sync selectedTask with URL params and live task updates
|
||||
useEffect(() => {
|
||||
if (taskId && tasks.length > 0) {
|
||||
const taskFromUrl = tasks.find((t) => t.id === taskId);
|
||||
if (taskFromUrl && taskFromUrl !== selectedTask) {
|
||||
setSelectedTask(taskFromUrl);
|
||||
if (taskId) {
|
||||
const t = taskId ? tasksById[taskId] : undefined;
|
||||
if (t) {
|
||||
setSelectedTask(t);
|
||||
setIsPanelOpen(true);
|
||||
}
|
||||
} else if (!taskId && selectedTask) {
|
||||
// Clear selection when no taskId in URL
|
||||
} else {
|
||||
setSelectedTask(null);
|
||||
setIsPanelOpen(false);
|
||||
}
|
||||
}, [taskId, tasks, selectedTask]);
|
||||
}, [taskId, tasksById]);
|
||||
|
||||
// Define task creation handler
|
||||
const handleCreateNewTask = useCallback(() => {
|
||||
@@ -127,58 +133,15 @@ export function ProjectTasks() {
|
||||
setIsTemplateManagerOpen(false);
|
||||
}, []);
|
||||
|
||||
const fetchTasks = useCallback(
|
||||
async (skipLoading = false) => {
|
||||
try {
|
||||
if (!skipLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
const result = await tasksApi.getAll(projectId!);
|
||||
// Only update if data has actually changed
|
||||
setTasks((prevTasks) => {
|
||||
const newTasks = result;
|
||||
if (JSON.stringify(prevTasks) === JSON.stringify(newTasks)) {
|
||||
return prevTasks; // Return same reference to prevent re-render
|
||||
}
|
||||
const handleDeleteTask = useCallback(async (taskId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||
|
||||
setSelectedTask((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const updatedSelectedTask = newTasks.find(
|
||||
(task) => task.id === prev.id
|
||||
);
|
||||
|
||||
if (JSON.stringify(prev) === JSON.stringify(updatedSelectedTask))
|
||||
return prev;
|
||||
return updatedSelectedTask || prev;
|
||||
});
|
||||
|
||||
return newTasks;
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Failed to load tasks');
|
||||
} finally {
|
||||
if (!skipLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||
|
||||
try {
|
||||
await tasksApi.delete(taskId);
|
||||
await fetchTasks();
|
||||
} catch (error) {
|
||||
setError('Failed to delete task');
|
||||
}
|
||||
},
|
||||
[fetchTasks]
|
||||
);
|
||||
try {
|
||||
await tasksApi.delete(taskId);
|
||||
} catch (error) {
|
||||
setError('Failed to delete task');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditTask = useCallback(
|
||||
(task: Task) => {
|
||||
@@ -222,40 +185,27 @@ export function ProjectTasks() {
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || !active.data.current) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const draggedTaskId = active.id as string;
|
||||
const newStatus = over.id as Task['status'];
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
|
||||
const task = tasksById[draggedTaskId];
|
||||
if (!task || task.status === newStatus) return;
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
const previousStatus = task.status;
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
||||
);
|
||||
|
||||
try {
|
||||
await tasksApi.update(taskId, {
|
||||
await tasksApi.update(draggedTaskId, {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: newStatus,
|
||||
parent_task_attempt: task.parent_task_attempt,
|
||||
image_ids: null,
|
||||
});
|
||||
// UI will update via SSE stream
|
||||
} catch (err) {
|
||||
// Revert the optimistic update if the API call failed
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskId ? { ...t, status: previousStatus } : t
|
||||
)
|
||||
);
|
||||
setError('Failed to update task status');
|
||||
}
|
||||
},
|
||||
[tasks]
|
||||
[tasksById]
|
||||
);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
@@ -267,52 +217,25 @@ export function ProjectTasks() {
|
||||
onC: handleCreateNewTask,
|
||||
});
|
||||
|
||||
// Initialize data when projectId changes
|
||||
// Initialize project when projectId changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchProject();
|
||||
fetchTasks();
|
||||
|
||||
// Set up polling to refresh tasks every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
fetchTasks(true); // Skip loading spinner for polling
|
||||
}, 2000);
|
||||
|
||||
// Cleanup interval on unmount
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, fetchProject]);
|
||||
|
||||
// Handle direct navigation to task URLs
|
||||
useEffect(() => {
|
||||
if (taskId && tasks.length > 0) {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
setSelectedTask((prev) => {
|
||||
if (JSON.stringify(prev) === JSON.stringify(task)) return prev;
|
||||
return task;
|
||||
});
|
||||
setIsPanelOpen(true);
|
||||
} else {
|
||||
// Task not found in current array - refetch to get latest data
|
||||
fetchTasks(true);
|
||||
}
|
||||
} else if (taskId && tasks.length === 0 && !loading) {
|
||||
// If we have a taskId but no tasks loaded, fetch tasks
|
||||
fetchTasks();
|
||||
} else if (!taskId) {
|
||||
// Close panel when no taskId in URL
|
||||
setIsPanelOpen(false);
|
||||
setSelectedTask(null);
|
||||
}
|
||||
}, [taskId, tasks, loading, fetchTasks]);
|
||||
// Remove legacy direct-navigation handler; live sync above covers this
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return <Loader message="Loading tasks..." size={32} className="py-8" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-center py-8 text-destructive">{error}</div>;
|
||||
if (error || streamError) {
|
||||
return (
|
||||
<div className="text-center py-8 text-destructive">
|
||||
{error || streamError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user