Skip to main content
Glama

Task Manager MCP Server

by jhawkins11
+page.svelte38.4 kB
<script lang="ts"> import { onMount, onDestroy } from 'svelte'; import { page } from '$app/stores'; import { fade } from 'svelte/transition'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; import { Checkbox } from '$lib/components/ui/checkbox'; import * as Select from '$lib/components/ui/select'; import { Progress } from '$lib/components/ui/progress'; import { Loader2, CornerDownLeft, CornerDownRight, Pencil, Trash2, FileText, Eye } from 'lucide-svelte'; import { writable, type Writable } from 'svelte/store'; import type { Task, WebSocketMessage, ShowQuestionPayload, QuestionResponsePayload } from '$lib/types'; import { TaskStatus, TaskEffort } from '$lib/types'; import type { Selected } from 'bits-ui'; import QuestionModal from '$lib/components/QuestionModal.svelte'; import TaskFormModal from '$lib/components/TaskFormModal.svelte'; import ImportTasksModal from '$lib/components/ImportTasksModal.svelte'; // Convert to writable stores for better state management const tasks: Writable<Task[]> = writable([]); let nestedTasks: Task[] = []; const loading: Writable<boolean> = writable(true); const error: Writable<string | null> = writable(null); let featureId: string | null = null; let features: string[] = []; let loadingFeatures = true; let ws: WebSocket | null = null; let wsStatus: 'connecting' | 'connected' | 'disconnected' = 'disconnected'; // Question modal state let showQuestionModal = false; let questionData: ShowQuestionPayload | null = null; let selectedOption = ''; let userResponse = ''; // Task form modal state let showTaskFormModal = false; let editingTask: Task | null = null; let isEditing = false; let waitingOnLLM = false; let showImportModal = false; // Reactive statement to update nestedTasks when tasks store changes $: { const taskMap = new Map<string, Task & { children: Task[] }>(); const rootTasks: Task[] = []; // Use the tasks from the store ($tasks) $tasks.forEach(task => { // Ensure the task object has the correct type including children array const taskWithChildren: Task & { children: Task[] } = { ...task, children: [] }; taskMap.set(task.id, taskWithChildren); }); $tasks.forEach(task => { const currentTask = taskMap.get(task.id)!; // Should always exist if (task.parentTaskId && taskMap.has(task.parentTaskId)) { taskMap.get(task.parentTaskId)!.children.push(currentTask); } else { rootTasks.push(currentTask); } }); nestedTasks = rootTasks; } // --- WebSocket Functions --- function connectWebSocket() { // Construct WebSocket URL (ws:// or wss:// based on protocol) const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}`; console.log(`[WS Client] Attempting to connect to ${wsUrl}...`); wsStatus = 'connecting'; ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('[WS Client] WebSocket connection established.'); wsStatus = 'connected'; // Register this client for the current feature if (featureId && ws) { sendWsMessage({ type: 'client_registration', featureId: featureId, payload: { featureId: featureId, clientId: `browser-${Date.now()}` } // Basic client ID }); } }; ws.onmessage = (event) => { try { const message: WebSocketMessage = JSON.parse(event.data); console.log('[WS Client] Received message:', message); // Check if the message is for the currently viewed feature if (message.featureId && message.featureId !== featureId) { console.log('[WS Client] Ignoring message for different feature:', message.featureId); return; } switch (message.type) { case 'tasks_updated': waitingOnLLM = false; console.log(`[WS Client] Received tasks_updated for feature ${featureId}:`, message.payload.tasks); if (message.payload?.tasks && Array.isArray(message.payload.tasks) && featureId) { // Map incoming tasks using the helper function to ensure consistency const mappedTasks = message.payload.tasks.map((task: any) => mapApiTaskToClientTask(task, featureId as string) ); tasks.set(mappedTasks); // Detailed logging after update tasks.subscribe(currentTasks => { console.log('[WS Client] tasks store after set():', currentTasks); })(); // Explicitly set loading to false loading.set(false); error.set(null); // Clear any previous errors } else { console.warn('[WS Client] Invalid or missing tasks payload for tasks_updated message.'); // Optionally handle this case, e.g., set an error or leave tasks unchanged } break; case 'status_changed': console.log(`[WS Client] Received status_changed for task ${message.payload?.taskId}`); if (message.payload?.taskId && message.payload?.status) { // Map incoming status string to TaskStatus enum let newStatus: TaskStatus; switch (message.payload.status) { case 'completed': newStatus = TaskStatus.COMPLETED; break; case 'in_progress': newStatus = TaskStatus.IN_PROGRESS; break; case 'decomposed': newStatus = TaskStatus.DECOMPOSED; break; default: newStatus = TaskStatus.PENDING; break; } tasks.update(currentTasks => currentTasks.map(task => task.id === message.payload.taskId ? { ...task, status: newStatus, // Completed is true ONLY if status is COMPLETED completed: newStatus === TaskStatus.COMPLETED } : task ) ); // Status change doesn't imply general loading state change } break; case 'show_question': waitingOnLLM = false; console.log('[WS Client] Received clarification question:', message.payload); // Store question data and show modal questionData = message.payload as ShowQuestionPayload; showQuestionModal = true; // When question arrives, we should stop loading indicator loading.set(false); break; case 'error': waitingOnLLM = false; console.error('[WS Client] Received error message:', message.payload); // Display user-facing error error.set(message.payload?.message || 'Received error from server.'); // Error likely means loading is done (with an error) loading.set(false); break; case 'task_created': console.log('[WS Client] Received task_created:', message.payload); if (message.payload?.task) { // Map incoming task to our Task type const newTask = mapApiTaskToClientTask(message.payload.task, message.featureId || featureId || ''); // Add the new task to the store tasks.update(currentTasks => [...currentTasks, newTask]); // Process nested structure processNestedTasks(); } break; case 'task_updated': console.log('[WS Client] Received task_updated:', message.payload); if (message.payload?.task) { // Map incoming task to our Task type const updatedTask = mapApiTaskToClientTask(message.payload.task, message.featureId || featureId || ''); // Update the existing task in the store tasks.update(currentTasks => currentTasks.map(task => task.id === updatedTask.id ? updatedTask : task ) ); // Process nested structure processNestedTasks(); } break; case 'task_deleted': console.log('[WS Client] Received task_deleted:', message.payload); if (message.payload?.taskId) { // Remove the task from the store tasks.update(currentTasks => currentTasks.filter(task => task.id !== message.payload.taskId) ); // Process nested structure processNestedTasks(); } break; case 'connection_established': console.log('[WS Client] Server confirmed connection.'); break; case 'client_registration': console.log('[WS Client] Server confirmed registration:', message.payload); break; // Add other message type handlers if needed } } catch (e) { console.error('[WS Client] Error processing message:', e); loading.set(false); // Ensure loading is set to false on error } }; ws.onerror = (error) => { console.error('[WS Client] WebSocket error:', error); wsStatus = 'disconnected'; loading.set(false); // Ensure loading is false on WebSocket error }; ws.onclose = (event) => { console.log(`[WS Client] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason}`); wsStatus = 'disconnected'; ws = null; // Ensure loading is false when WebSocket disconnects loading.set(false); }; } function sendWsMessage(message: WebSocketMessage) { if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(message)); console.log('[WS Client] Sent message:', message); } catch (e) { console.error('[WS Client] Error sending message:', e); } } else { console.warn('[WS Client] Cannot send message, WebSocket not open.'); } } // --- Component Lifecycle & Data Fetching --- async function fetchTasks(selectedFeatureId?: string) { loading.set(true); error.set(null); try { // Construct the API endpoint based on whether we have a featureId const endpoint = selectedFeatureId ? `/api/tasks/${selectedFeatureId}` : '/api/tasks'; const response = await fetch(endpoint); if (!response.ok) { throw new Error(`Failed to fetch tasks: ${response.statusText}`); } const data = await response.json(); // Convert API response to our Task type const mappedData = data.map((task: any) => { // Map incoming status string to TaskStatus enum let status: TaskStatus; switch (task.status) { case 'completed': status = TaskStatus.COMPLETED; break; case 'in_progress': status = TaskStatus.IN_PROGRESS; break; case 'decomposed': status = TaskStatus.DECOMPOSED; break; default: status = TaskStatus.PENDING; break; } // Ensure effort is one of our enum values let effort: TaskEffort = TaskEffort.MEDIUM; // Default if (task.effort === 'low') { effort = TaskEffort.LOW; } else if (task.effort === 'high') { effort = TaskEffort.HIGH; } // Derive title from description if not present const title = task.title || task.description; // Ensure completed flag is consistent with status const completed = status === TaskStatus.COMPLETED; // Return the fully mapped task return { id: task.id, title, description: task.description, status, completed, effort, feature_id: task.feature_id || selectedFeatureId || undefined, parentTaskId: task.parentTaskId, createdAt: task.createdAt, updatedAt: task.updatedAt, fromReview: task.fromReview } as Task; }); tasks.set(mappedData); if (mappedData.length === 0) { error.set('No tasks found for this feature.'); } } catch (err) { error.set(err instanceof Error ? err.message : 'An error occurred'); // Add more detailed logging console.error('[fetchTasks] Error fetching tasks:', err); if (err instanceof Error && err.cause) { console.error('[fetchTasks] Error Cause:', err.cause); } tasks.set([]); // Clear any previous tasks } finally { // Always reset loading state when fetch completes loading.set(false); } } async function fetchFeatures() { loadingFeatures = true; try { const response = await fetch('/api/features'); if (!response.ok) { throw new Error('Failed to fetch features'); } features = await response.json(); } catch (err) { console.error('Error fetching features:', err); features = []; } finally { loadingFeatures = false; } } // New function to fetch pending question async function fetchPendingQuestion(id: string): Promise<ShowQuestionPayload | null> { console.log(`[Pending Question] Checking for feature ${id}...`); try { const response = await fetch(`/api/features/${id}/pending-question`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data: ShowQuestionPayload | null = await response.json(); if (data) { console.log('[Pending Question] Found pending question:', data); return data; } else { console.log('[Pending Question] No pending question found.'); return null; } } catch (err) { console.error('[Pending Question] Error fetching pending question:', err); error.set(err instanceof Error ? `Error checking for pending question: ${err.message}` : 'An error occurred while checking for pending questions.'); return null; } } function processNestedTasks() { // Define the type for map values explicitly type TaskWithChildren = Task & { children: Task[] }; const taskMap = new Map<string, TaskWithChildren>( $tasks.map(task => [task.id, { ...task, children: [] }]) ); const rootTasks: Task[] = []; taskMap.forEach((task: TaskWithChildren) => { if (task.parentTaskId && taskMap.has(task.parentTaskId)) { const parent = taskMap.get(task.parentTaskId); if (parent) { parent.children.push(task); } else { rootTasks.push(task); } } else { rootTasks.push(task); } }); // Optional: Sort root tasks or children if needed // rootTasks.sort(...); // taskMap.forEach(task => task.children.sort(...)); nestedTasks = rootTasks; } async function addTask(taskData: { title: string; effort: string; featureId: string, description?: string }) { try { const response = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...taskData, description: taskData.description || taskData.title // Use provided description, or title if none }) }); if (!response.ok) { throw new Error(`Failed to create task: ${response.statusText}`); } const newTask = await response.json(); console.log('[Task] New task created:', newTask); // Refresh the tasks list await fetchTasks(taskData.featureId); // Clear any errors that might have been shown error.set(null); } catch (err) { console.error('[Task] Error creating task:', err); error.set(err instanceof Error ? err.message : 'Failed to create task'); } } function handleTaskFormSubmit(event: CustomEvent) { const taskData = event.detail; if (isEditing && editingTask) { updateTask(editingTask.id, taskData); } else { addTask(taskData); } showTaskFormModal = false; isEditing = false; editingTask = null; } function openEditTaskModal(task: Task) { editingTask = task; isEditing = true; showTaskFormModal = true; } async function updateTask(taskId: string, taskData: { title: string; effort: string; featureId: string }) { try { const response = await fetch(`/api/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...taskData, description: taskData.title, // Use title as description featureId: taskData.featureId }) }); if (!response.ok) { throw new Error(`Failed to update task: ${response.statusText}`); } const updatedTask = await response.json(); console.log('[Task] Task updated:', updatedTask); // Refresh the tasks list await fetchTasks(taskData.featureId); // Clear any errors that might have been shown error.set(null); } catch (err) { console.error('[Task] Error updating task:', err); error.set(err instanceof Error ? err.message : 'Failed to update task'); } } async function deleteTask(taskId: string, featureId: string) { if (!confirm('Are you sure you want to delete this task?')) { return; } try { const response = await fetch(`/api/tasks/${taskId}?featureId=${featureId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(`Failed to delete task: ${response.statusText}`); } console.log('[Task] Task deleted:', taskId); // Refresh the tasks list await fetchTasks(featureId); // Clear any errors that might have been shown error.set(null); } catch (err) { console.error('[Task] Error deleting task:', err); error.set(err instanceof Error ? err.message : 'Failed to delete task'); } } onMount(async () => { loading.set(true); // Set loading true at the start error.set(null); // Reset error // Extract featureId from URL query parameters featureId = $page.url.searchParams.get('featureId'); // Fetch available features first await fetchFeatures(); // Determine the featureId to use (from URL or latest) if (!featureId && features.length > 0) { // Attempt to fetch default tasks to find the latest featureId await fetchTasks(); if ($tasks.length > 0 && $tasks[0]?.feature_id) { featureId = $tasks[0].feature_id; console.log(`[onMount] Determined latest featureId: ${featureId}`); } else { console.log('[onMount] Could not determine latest featureId from default tasks.'); // If no featureId determined, use the first from the list if available if (features.length > 0) { featureId = features[0]; console.log(`[onMount] Using first available featureId: ${featureId}`); } } } // Now, if we have a featureId, check for pending questions and fetch tasks if (featureId) { console.log(`[onMount] Operating with featureId: ${featureId}`); // Check for pending question first const pendingQuestion = await fetchPendingQuestion(featureId); if (pendingQuestion) { questionData = pendingQuestion; showQuestionModal = true; // Still fetch tasks even if question is shown, they might exist await fetchTasks(featureId); } else { // No pending question, just fetch tasks await fetchTasks(featureId); } } else { // No featureId could be determined console.log('[onMount] No featureId available.'); if (!$error) { // Only set error if fetchTasks didn't already set one error.set('No features found. Create a feature first using the task manager CLI.'); } tasks.set([]); // Ensure tasks are empty nestedTasks = []; } // Connect WebSocket AFTER initial data load and featureId determination if (featureId) { connectWebSocket(); } }); onDestroy(() => { // Clean up WebSocket connection if (ws) { console.log('[WS Client] Closing WebSocket connection.'); ws.close(); ws = null; } }); async function toggleTaskStatus(taskId: string) { const tasksList = $tasks; const taskIndex = tasksList.findIndex((t) => t.id === taskId); if (taskIndex !== -1) { const task = tasksList[taskIndex]; const newStatus = task.status === TaskStatus.COMPLETED ? TaskStatus.PENDING : TaskStatus.COMPLETED; try { // Make API call to update status in backend const response = await fetch(`/api/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ featureId: task.feature_id, status: newStatus, completed: newStatus === TaskStatus.COMPLETED }) }); if (!response.ok) { throw new Error('Failed to update task status'); } // Optionally update local store or rely on WebSocket update } catch (err) { console.error('Failed to update task status:', err); // Optionally show error to user } } } function getEffortBadgeVariant(effort: string) { switch (effort) { case TaskEffort.LOW: return 'secondary'; case TaskEffort.MEDIUM: return 'default'; case TaskEffort.HIGH: return 'destructive'; default: return 'outline'; } } function getStatusBadgeVariant(status: TaskStatus): 'default' | 'secondary' | 'destructive' | 'outline' { switch (status) { case TaskStatus.COMPLETED: return 'secondary'; case TaskStatus.IN_PROGRESS: return 'default'; case TaskStatus.DECOMPOSED: return 'outline'; case TaskStatus.PENDING: default: return 'outline'; } } function refreshTasks() { if ($loading) return; console.log('[Task List] Refreshing tasks...'); fetchTasks(featureId || undefined); } function handleFeatureChange(selectedItem: Selected<string> | undefined) { const newFeatureId = selectedItem?.value; // Safely get value if (newFeatureId && newFeatureId !== featureId) { featureId = newFeatureId; // Update URL const url = new URL(window.location.href); url.searchParams.set('featureId', newFeatureId); window.history.pushState({}, '', url); // Fetch tasks for the new feature fetchTasks(newFeatureId); // Re-register WebSocket for the new feature if (ws && wsStatus === 'connected') { sendWsMessage({ type: 'client_registration', featureId: featureId, payload: { featureId: featureId, clientId: `browser-${Date.now()}` } }); } } } // Handle user response to clarification question function handleQuestionResponse(event: SubmitEvent) { event.preventDefault(); console.log('[WS Client] User responded to question. Selected:', selectedOption, 'Text:', userResponse); if (questionData && featureId) { const response = selectedOption || userResponse; sendWsMessage({ type: 'question_response', featureId, payload: { questionId: questionData.questionId, response: response } as QuestionResponsePayload }); showQuestionModal = false; questionData = null; waitingOnLLM = true; // Reset form fields selectedOption = ''; userResponse = ''; } } // Handle user cancellation of question function handleQuestionCancel() { console.log('[WS Client] User cancelled question'); showQuestionModal = false; questionData = null; } // ... reactive variables ... // Filter out decomposed tasks from progress calculation $: actionableTasks = $tasks.filter(t => t.status !== TaskStatus.DECOMPOSED); $: completedCount = actionableTasks.filter(t => t.completed).length; $: totalActionableTasks = actionableTasks.length; $: progress = totalActionableTasks > 0 ? (completedCount / totalActionableTasks) * 100 : 0; $: firstPendingTaskIndex = $tasks.findIndex(t => t.status === TaskStatus.PENDING); $: selectedFeatureLabel = features.find(f => f === featureId) || 'Select Feature'; // Call processNestedTasks whenever the raw tasks array changes $: { if ($tasks) { processNestedTasks(); } } // Helper function to map API task response to client Task type function mapApiTaskToClientTask(apiTask: any, currentFeatureId: string): Task { // Map incoming status string to TaskStatus enum let status: TaskStatus; switch (apiTask.status) { case 'completed': status = TaskStatus.COMPLETED; break; case 'in_progress': status = TaskStatus.IN_PROGRESS; break; case 'decomposed': status = TaskStatus.DECOMPOSED; break; default: status = TaskStatus.PENDING; break; } // Ensure effort is one of our enum values let effort: TaskEffort = TaskEffort.MEDIUM; // Default if (apiTask.effort === 'low') { effort = TaskEffort.LOW; } else if (apiTask.effort === 'high') { effort = TaskEffort.HIGH; } // Derive title from description if not present const title = apiTask.title || apiTask.description; // Ensure completed flag is consistent with status const completed = status === TaskStatus.COMPLETED; // Return the fully mapped task return { id: apiTask.id, title, description: apiTask.description, status, completed, effort, feature_id: apiTask.feature_id || currentFeatureId, parentTaskId: apiTask.parentTaskId, createdAt: apiTask.createdAt, updatedAt: apiTask.updatedAt, fromReview: apiTask.fromReview } as Task; } async function handleImportTasks(event: CustomEvent) { const { tasks } = event.detail; if (!Array.isArray(tasks)) return; for (const t of tasks) { await addTask({ title: t.title, effort: t.effort, featureId: featureId || '', description: t.description }); } showImportModal = false; } function handleCancelImport() { showImportModal = false; } </script> <div class="container mx-auto py-10 px-4 sm:px-6 lg:px-8 max-w-5xl"> <div class="flex justify-between items-center mb-8"> <h1 class="text-3xl font-bold tracking-tight text-foreground">Task Manager</h1> {#if features.length > 0} <div class="w-64"> <Select.Root onSelectedChange={handleFeatureChange} selected={featureId ? { value: featureId, label: featureId } : undefined} disabled={loadingFeatures} > <Select.Trigger class="w-full"> {featureId ? featureId.substring(0, 8) + '...' : 'Select Feature'} </Select.Trigger> <Select.Content> <Select.Group> <Select.GroupHeading>Available Features</Select.GroupHeading> {#each features as feature} <Select.Item value={feature} label={feature}>{feature.substring(0, 8)}...</Select.Item> {/each} </Select.Group> </Select.Content> </Select.Root> </div> {/if} </div> {#if questionData} <div class="flex flex-col items-center justify-center min-h-[300px]"> <div class="max-w-md w-full bg-background border border-border rounded-lg shadow-lg p-6"> <h2 class="text-xl font-semibold mb-4">Clarification Needed</h2> <p class="text-foreground mb-5">{questionData.question}</p> <form on:submit|preventDefault={handleQuestionResponse}> {#if questionData.options && questionData.options.length > 0} <div class="flex flex-col gap-3 mb-5"> {#each questionData.options as option} <label class="flex items-center gap-2 p-3 border border-border rounded-md cursor-pointer hover:bg-muted transition-colors"> <input type="radio" name="option" value={option} bind:group={selectedOption} class="focus:ring-primary" /> <span class="text-foreground">{option}</span> </label> {/each} </div> {/if} {#if questionData.allowsText !== false} <div class="mb-5"> <label for="text-response" class="block mb-2 font-medium text-foreground"> {questionData.options && questionData.options.length > 0 ? 'Or provide a custom response:' : 'Your response:'} </label> <textarea id="text-response" rows="3" bind:value={userResponse} placeholder="Type your response here..." class="w-full p-3 border border-border rounded-md resize-y text-foreground bg-background focus:ring-primary focus:border-primary" ></textarea> </div> {/if} <div class="flex justify-end gap-3 pt-2"> <button type="submit" class="bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md font-medium text-sm disabled:opacity-50" disabled={!userResponse && (!questionData.options || !selectedOption)} > Submit Response </button> </div> </form> </div> </div> {:else if waitingOnLLM} <div class="flex flex-col items-center justify-center min-h-[300px]"> <Loader2 class="h-12 w-12 animate-spin text-primary mb-4" /> <p class="text-lg text-muted-foreground">Waiting on LLM to plan after clarification...</p> </div> {:else if $loading} <div class="flex justify-center items-center h-64"> <Loader2 class="h-12 w-12 animate-spin text-primary" /> </div> {:else if $error} <Card class="mb-6 border-destructive"> <CardHeader> <CardTitle class="text-destructive">Error Loading Tasks</CardTitle> <CardDescription class="text-destructive/90">{$error}</CardDescription> </CardHeader> </Card> {:else} <Card class="shadow-lg"> <CardHeader class="border-b border-border px-6 py-4"> <CardTitle class="text-xl font-semibold flex justify-between items-center"> <span class="flex-1">Tasks</span> <div class="flex justify-between items-center gap-4 items-center"> <Badge variant="secondary">{$tasks.length}</Badge> <Button on:click={() => showImportModal = true}>Import Tasks</Button> </div> </CardTitle> <CardDescription class="mt-1"> Manage your tasks and track progress for the selected feature. </CardDescription> <div class="pt-4"> <Progress value={progress} class="w-full h-2 [&>div]:bg-green-500 [&>div]:transition-all [&>div]:duration-300 [&>div]:ease-in-out" /> </div> </CardHeader> <CardContent class="p-0"> <div class="divide-y divide-border"> {#each nestedTasks as task (task.id)} {@const taskIndexInFlatList = $tasks.findIndex(t => t.id === task.id)} {@const isNextPending = taskIndexInFlatList === firstPendingTaskIndex} {@const isInProgress = task.status === TaskStatus.IN_PROGRESS} {@const areAllChildrenComplete = task.children && task.children.length > 0 && task.children.every(c => c.status === TaskStatus.COMPLETED)} <div transition:fade={{ duration: 200 }} class="task-row flex items-start space-x-4 p-4 hover:bg-muted/50 transition-colors {isNextPending ? 'bg-muted/30' : ''} {isInProgress ? 'in-progress-shine relative overflow-hidden' : ''} {(task.status === TaskStatus.COMPLETED || (task.status === TaskStatus.DECOMPOSED && areAllChildrenComplete)) ? 'opacity-60' : ''} {task.fromReview ? 'from-review-task' : ''}" > {#if task.status === TaskStatus.DECOMPOSED} <div class="flex items-center justify-center h-6 w-6 mt-1 text-muted-foreground"> <CornerDownRight class="h-4 w-4" /> </div> {:else} <div class="flex flex-col items-center gap-1"> <Checkbox id={`task-${task.id}`} checked={task.completed} onCheckedChange={() => toggleTaskStatus(task.id)} aria-labelledby={`task-label-${task.id}`} disabled={task.status === TaskStatus.IN_PROGRESS} /> {#if task.fromReview} <span class="review-indicator" title="Task from review"> <Eye size={20} /> </span> {/if} </div> {/if} <div class="flex-1 grid gap-1"> <div class="flex items-center gap-2"> <label for={`task-${task.id}`} id={`task-label-${task.id}`} class={`font-medium cursor-pointer ${(task.status === TaskStatus.COMPLETED || (task.status === TaskStatus.DECOMPOSED && areAllChildrenComplete)) ? 'line-through text-muted-foreground' : ''}`} > {task.title} </label> </div> {#if task.description && task.description !== task.title} <p class="text-sm text-muted-foreground"> {task.description} </p> {/if} </div> <div class="flex flex-col gap-1.5 items-end min-w-[100px]"> <div class="flex items-center gap-1.5"> <Badge variant={getStatusBadgeVariant(task.status)} class="capitalize"> {task.status.replace('_', ' ')} </Badge> </div> {#if task.effort} <Badge variant={getEffortBadgeVariant(task.effort)} class="capitalize"> {task.effort} </Badge> {/if} </div> <div class="flex gap-1 ml-4"> <button class="text-muted-foreground hover:text-foreground p-1 rounded-sm hover:bg-muted transition-colors" title="Edit task" on:click|stopPropagation={() => openEditTaskModal(task)} > <Pencil size={16} /> </button> <button class="text-muted-foreground hover:text-destructive p-1 rounded-sm hover:bg-muted transition-colors" title="Delete task" on:click|stopPropagation={() => deleteTask(task.id, featureId || '')} > <Trash2 size={16} /> </button> </div> </div> {#if task.children && task.children.length > 0} <div class="ml-10 pl-4 py-2 border-l border-border divide-y divide-border"> {#each task.children as childTask (childTask.id)} {@const childTaskIndexInFlatList = $tasks.findIndex(t => t.id === childTask.id)} {@const isChildNextPending = childTaskIndexInFlatList === firstPendingTaskIndex} {@const isChildInProgress = childTask.status === TaskStatus.IN_PROGRESS} <div transition:fade={{ duration: 200 }} class="task-row flex items-start space-x-4 pt-3 pr-4 mb-3 {isChildNextPending ? 'bg-muted/30' : ''} {isChildInProgress ? 'in-progress-shine relative overflow-hidden' : ''} {childTask.status === TaskStatus.COMPLETED ? 'opacity-60' : ''} {childTask.fromReview ? 'from-review-task' : ''}" > {#if childTask.status === TaskStatus.DECOMPOSED} <div class="flex items-center justify-center h-6 w-6 mt-1 text-muted-foreground"> <CornerDownRight class="h-4 w-4" /> </div> {:else} <div class="flex flex-col items-center gap-1"> <Checkbox id={`task-${childTask.id}`} checked={childTask.completed} onCheckedChange={() => toggleTaskStatus(childTask.id)} aria-labelledby={`task-label-${childTask.id}`} disabled={childTask.status === TaskStatus.IN_PROGRESS} /> {#if childTask.fromReview} <span class="review-indicator" title="Task from review"> <Eye size={20} /> </span> {/if} </div> {/if} <div class="flex-1 grid gap-1"> <div class="flex items-center gap-2"> <label for={`task-${childTask.id}`} id={`task-label-${childTask.id}`} class={`font-medium cursor-pointer ${childTask.status === TaskStatus.COMPLETED ? 'line-through text-muted-foreground' : ''}`} > {childTask.title} </label> </div> {#if childTask.description && childTask.description !== childTask.title} <p class="text-sm text-muted-foreground"> {childTask.description} </p> {/if} </div> <div class="flex flex-col gap-1.5 items-end min-w-[100px]"> <div class="flex items-center gap-1.5"> <Badge variant={getStatusBadgeVariant(childTask.status)} class="capitalize"> {childTask.status.replace('_', ' ')} </Badge> </div> {#if childTask.effort} <Badge variant={getEffortBadgeVariant(childTask.effort)} class="capitalize"> {childTask.effort} </Badge> {/if} </div> <div class="flex gap-1 ml-4"> <button class="text-muted-foreground hover:text-foreground p-1 rounded-sm hover:bg-muted transition-colors" title="Edit subtask" on:click|stopPropagation={() => openEditTaskModal(childTask)} > <Pencil size={16} /> </button> <button class="text-muted-foreground hover:text-destructive p-1 rounded-sm hover:bg-muted transition-colors" title="Delete subtask" on:click|stopPropagation={() => deleteTask(childTask.id, featureId || '')} > <Trash2 size={16} /> </button> </div> </div> {/each} </div> {/if} {:else} <div class="text-center py-8 text-muted-foreground"> No tasks found for this feature. </div> {/each} </div> </CardContent> <CardFooter class="flex flex-col items-start gap-4 px-6 py-4 border-t border-border"> <div class="w-full flex justify-between items-center"> <span class="text-sm text-muted-foreground"> {completedCount} of {totalActionableTasks} actionable tasks completed </span> <div class="flex gap-2"> <Button variant="outline" size="sm" on:click={() => showTaskFormModal = true} disabled={!featureId}> Add Task </Button> <Button variant="outline" size="sm" on:click={refreshTasks} disabled={$loading}> {#if $loading} <Loader2 class="mr-2 h-4 w-4 animate-spin" /> {/if} Refresh </Button> </div> </div> </CardFooter> </Card> {/if} {#if featureId} <TaskFormModal open={showTaskFormModal} featureId={featureId} isEditing={isEditing} editTask={editingTask ? { id: editingTask.id, title: editingTask.title || '', effort: editingTask.effort || 'medium' } : { id: '', title: '', effort: 'medium' }} on:submit={handleTaskFormSubmit} on:cancel={() => showTaskFormModal = false} /> {/if} <ImportTasksModal bind:open={showImportModal} on:import={handleImportTasks} on:cancel={handleCancelImport} /> </div> <style> .in-progress-shine::before { content: ''; position: absolute; top: 0; left: -100%; /* Start off-screen */ width: 75%; /* Width of the shine */ height: 100%; background: linear-gradient( 100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.15) 50%, /* Subtle white shine */ rgba(255, 255, 255, 0) 100% ); transform: skewX(-25deg); /* Angle the shine */ animation: shine 2.5s infinite linear; /* Animation properties */ opacity: 0.8; } @keyframes shine { 0% { left: -100%; } 50%, 100% { /* Speed up the animation and make it pause less */ left: 120%; /* Move across and off-screen */ } } .task-row { position: relative; /* Needed for absolute positioning of ::before */ overflow: hidden; /* Keep shine contained */ } .review-indicator { display: inline-flex; align-items: center; justify-content: center; color: #3b82f6; transition: all 0.2s ease; margin-top: 10px; } .review-indicator:hover { opacity: 0.8; } .from-review-task { background-color: rgba(59, 130, 246, 0.08); } .from-review-task:hover { background-color: rgba(59, 130, 246, 0.12); } </style>

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jhawkins11/task-manager-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server