Skip to main content
Glama
TaskTable.jsx27.2 kB
import React, { useMemo, useState, useEffect } from 'react'; import { useReactTable, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, flexRender, } from '@tanstack/react-table'; import TaskDetailView from './TaskDetailView'; import Tooltip from './Tooltip'; import AgentInfoModal from './AgentInfoModal'; import { useTranslation } from 'react-i18next'; import { generateTaskNumbers, getTaskNumber, convertDependenciesToNumbers, getTaskByNumber } from '../utils/taskNumbering'; function TaskTable({ data, globalFilter, onGlobalFilterChange, projectRoot, onDetailViewChange, resetDetailView, profileId, onTaskSaved, onDeleteTask, showToast }) { const { t } = useTranslation(); const [selectedTask, setSelectedTask] = useState(null); const [availableAgents, setAvailableAgents] = useState([]); const [savingAgents, setSavingAgents] = useState({}); const [agentModalInfo, setAgentModalInfo] = useState({ isOpen: false, agent: null, taskId: null }); const [selectedRows, setSelectedRows] = useState(new Set()); const [showBulkActions, setShowBulkActions] = useState(false); const [loading, setLoading] = useState(false); // Generate task number mapping const taskNumberMap = useMemo(() => generateTaskNumbers(data), [data]); // Load available agents on mount useEffect(() => { const loadAgents = async () => { if (!profileId) return; try { const response = await fetch(`/api/agents/combined/${profileId}`); if (response.ok) { const agents = await response.json(); setAvailableAgents(agents); } } catch (err) { console.error('Error loading agents:', err); } }; loadAgents(); }, [profileId]); // Notify parent when entering/exiting edit mode useEffect(() => { if (selectedTask) { if (selectedTask.editMode) { // Entering edit mode if (onDetailViewChange) { onDetailViewChange(true, true, selectedTask?.id); } } else { // In detail view but not edit mode if (onDetailViewChange) { onDetailViewChange(true, false, selectedTask?.id); } } } else { // Not in any detail view if (onDetailViewChange) { onDetailViewChange(false, false, null); } } }, [selectedTask, onDetailViewChange]); // Reset selected task when parent requests it useEffect(() => { console.log('TaskTable: resetDetailView changed to:', resetDetailView); if (resetDetailView && resetDetailView > 0) { console.log('TaskTable: Resetting selected task to null'); setSelectedTask(null); } }, [resetDetailView]); // Notify parent when detail view changes useEffect(() => { if (onDetailViewChange) { onDetailViewChange(!!selectedTask, selectedTask?.editMode || false, selectedTask?.id || null); } }, [selectedTask, onDetailViewChange]); // Define table columns configuration with custom cell renderers const columns = useMemo(() => [ { id: 'select', header: ({ table }) => { const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length; const checkboxRef = React.useRef(null); React.useEffect(() => { if (checkboxRef.current) { checkboxRef.current.indeterminate = isIndeterminate; } }, [isIndeterminate]); return ( <input ref={checkboxRef} type="checkbox" checked={selectedRows.size === data.length && data.length > 0} onChange={(e) => { if (e.target.checked) { setSelectedRows(new Set(data.map(task => task.id))); } else { setSelectedRows(new Set()); } setShowBulkActions(e.target.checked); }} /> ); }, cell: ({ row }) => { const isChecked = selectedRows.has(row.original.id); return ( <input key={`checkbox-${row.original.id}`} type="checkbox" checked={isChecked} onClick={(e) => { e.stopPropagation(); }} onChange={(e) => { e.stopPropagation(); const newSelectedRows = new Set(selectedRows); if (e.target.checked) { newSelectedRows.add(row.original.id); } else { newSelectedRows.delete(row.original.id); } setSelectedRows(newSelectedRows); setShowBulkActions(newSelectedRows.size > 0); }} /> ); }, size: 40, }, { accessorKey: 'taskNumber', header: 'Task', cell: ({ row }) => { const taskNumber = taskNumberMap[row.original.id] || row.index + 1; return ( <span className="task-number clickable" onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(row.original.id); const element = e.target; element.classList.add('copied'); setTimeout(() => { element.classList.remove('copied'); }, 2000); }} title={`${t('clickToCopyUuid')}: ${row.original.id}`} > Task {taskNumber} </span> ); }, size: 120, }, { accessorKey: 'name', header: t('task.name'), cell: ({ row }) => ( <div> <div className="task-name">{row.original.name}</div> <div className="task-meta"> <span className="task-id task-id-clickable" onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(row.original.id); const element = e.target; element.classList.add('copied'); setTimeout(() => { element.classList.remove('copied'); }, 2000); }} title={t('clickToCopyUuid')} > ID: {row.original.id.slice(0, 8)}... </span> </div> </div> ), size: 300, }, { accessorKey: 'description', header: t('description'), cell: ({ getValue }) => ( <div className="task-description"> {getValue()?.slice(0, 150)} {getValue()?.length > 150 ? '...' : ''} </div> ), size: 300, }, { accessorKey: 'status', header: t('task.status'), cell: ({ getValue }) => ( <span className={`status-badge status-${getValue()}`}> {getValue()?.replace('_', ' ')} </span> ), size: 120, }, { accessorKey: 'agent', header: 'Agent', cell: ({ row }) => { const currentAgent = row.original.agent || ''; const taskId = row.original.id; const isSaving = savingAgents[taskId] || false; return ( <div className="agent-cell-wrapper"> <select className="agent-table-select" value={currentAgent} style={(() => { // Find the selected agent's color const selectedAgent = availableAgents.find(a => a.name === currentAgent); if (selectedAgent?.color) { return { backgroundColor: selectedAgent.color, color: '#ffffff', // White text for better contrast borderColor: selectedAgent.color }; } return {}; })()} onChange={async (e) => { e.stopPropagation(); const newAgent = e.target.value; // Update saving state setSavingAgents(prev => ({ ...prev, [taskId]: true })); try { const response = await fetch(`/api/tasks/${profileId}/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ taskId: taskId, updates: { agent: newAgent } }) }); if (response.ok) { // Refresh task data if (onTaskSaved) { await onTaskSaved(); } } else { console.error('Failed to update agent'); } } catch (err) { console.error('Error updating agent:', err); } finally { // Clear saving state setSavingAgents(prev => { const newState = { ...prev }; delete newState[taskId]; return newState; }); } }} onClick={(e) => e.stopPropagation()} disabled={isSaving} > <option value="">No agent</option> {availableAgents.map((agent) => ( <option key={agent.name} value={agent.name} style={agent.color ? { backgroundColor: agent.color, color: '#ffffff' } : {}} > {agent.name} </option> ))} </select> <button className="agent-info-button" onClick={(e) => { e.stopPropagation(); if (currentAgent && currentAgent !== '') { // Pass the full agent object if available, otherwise just the name const agentData = availableAgents.find(a => a.name === currentAgent) || currentAgent; setAgentModalInfo({ isOpen: true, agent: agentData, taskId: taskId }); } }} disabled={!currentAgent || currentAgent === ''} title={currentAgent ? `View info for ${currentAgent}` : 'Select an agent first'} > 👁️ </button> {isSaving && <span className="saving-indicator">💾</span>} </div> ); }, size: 200, // Increased from 150 to give more room for agents }, { accessorKey: 'createdAt', header: t('created') + '/' + t('updated'), cell: ({ row }) => { const createdDate = new Date(row.original.createdAt); const updatedDate = new Date(row.original.updatedAt); return ( <div className="task-dates"> <div className="date-created"> <small style={{ color: '#666', fontSize: '10px' }}>{t('created')}:</small><br /> {createdDate.toLocaleDateString(undefined, { year: '2-digit', month: '2-digit', day: '2-digit' })}{' '} {createdDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: true })} </div> <div className="date-updated" style={{ marginTop: '4px' }}> <small style={{ color: '#666', fontSize: '10px' }}>{t('updated')}:</small><br /> {updatedDate.toLocaleDateString(undefined, { year: '2-digit', month: '2-digit', day: '2-digit' })}{' '} {updatedDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: true })} </div> </div> ); }, size: 140, }, { accessorKey: 'dependencies', header: t('task.dependencies'), cell: ({ row }) => { const dependencies = row.original.dependencies; if (!dependencies || !Array.isArray(dependencies) || dependencies.length === 0) { return <span style={{ color: '#666' }}>—</span>; } return ( <div className="dependencies-list"> {dependencies.map((dep, index) => { // Handle both string IDs and object dependencies let depId; if (typeof dep === 'string') { depId = dep; } else if (dep && typeof dep === 'object' && dep.id) { depId = dep.id; } else if (dep && typeof dep === 'object' && dep.taskId) { depId = dep.taskId; } else { // Skip invalid dependencies return null; } // Get task number for display const taskNumber = getTaskNumber(depId, taskNumberMap); const depTask = data.find(t => t.id === depId); const depTaskName = depTask ? depTask.name : 'Unknown Task'; return ( <Tooltip key={depId} content={`UUID: ${depId}`}> <span className="dependency-badge" onClick={(e) => { e.preventDefault(); e.stopPropagation(); // Find the task with this ID if (depTask) { setSelectedTask(depTask); } }} > Task {taskNumberMap[depId] || 'Unknown'} </span> </Tooltip> ); }).filter(Boolean)} </div> ); }, size: 120, // Reduced from 200 to make more room for agents }, { accessorKey: 'actions', header: t('actions'), cell: ({ row }) => ( <div className="actions-cell"> <button className="copy-button action-button" onClick={(e) => { e.stopPropagation(); const agentName = row.original.agent || 'task manager'; const instruction = agentName === 'task manager' ? `Use task manager to complete this shrimp task: ${row.original.id} please when u start working mark the shrimp task as in progress` : `use the built in subagent located in ./claude/agents/${agentName} to complete this shrimp task: ${row.original.id} please when u start working mark the shrimp task as in progress`; navigator.clipboard.writeText(instruction); const button = e.target; button.textContent = '✓'; setTimeout(() => { button.textContent = '🤖'; }, 2000); }} title={(() => { const agentName = row.original.agent || 'task manager'; return agentName === 'task manager' ? `Use task manager to complete this shrimp task: ${row.original.id} please when u start working mark the shrimp task as in progress` : `use the built in subagent located in ./claude/agents/${agentName} to complete this shrimp task: ${row.original.id} please when u start working mark the shrimp task as in progress`; })()} > 🤖 </button> <button className={`edit-button action-button ${row.original.status === 'completed' ? 'disabled' : ''}`} onClick={(e) => { e.stopPropagation(); if (row.original.status !== 'completed') { setSelectedTask({ ...row.original, editMode: true }); } }} disabled={row.original.status === 'completed'} title={row.original.status === 'completed' ? 'Cannot edit completed task' : `Edit task: ${row.original.id}`} > ✏️ </button> <button className="delete-button action-button" onClick={(e) => { e.stopPropagation(); if (confirm(t('confirmDeleteTask'))) { onDeleteTask(row.original.id); } }} title={`Delete task: ${row.original.id}`} > 🗑️ </button> </div> ), size: 100, }, ], [data, setSelectedTask, t, taskNumberMap, onDeleteTask, availableAgents, savingAgents, profileId, onTaskSaved, selectedRows]); const table = useReactTable({ data, columns, state: { globalFilter, }, onGlobalFilterChange, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), globalFilterFn: 'includesString', initialState: { pagination: { pageSize: 20, }, }, }); if (data.length === 0) { return ( <div className="empty-state"> <div className="empty-state-icon">📋</div> <div className="empty-state-title">{t('empty.noTasksFound')}</div> <div className="empty-state-message"> {t('noTasksMessage')} </div> </div> ); } // If a task is selected, show the detail view or edit view if (selectedTask) { if (selectedTask.editMode) { // Import will be added at the top const TaskEditView = React.lazy(() => import('./TaskEditView')); return ( <React.Suspense fallback={<div className="loading">Loading...</div>}> <TaskEditView task={selectedTask} onBack={() => setSelectedTask(null)} projectRoot={projectRoot} profileId={profileId} onNavigateToTask={(taskId) => { const targetTask = data.find(t => t.id === taskId); if (targetTask) { setSelectedTask(targetTask); } }} taskIndex={data.findIndex(t => t.id === selectedTask.id)} allTasks={data} onSave={async (updatedTask) => { // Close the edit view immediately setSelectedTask(null); // Notify parent to refresh data if (onDetailViewChange) { onDetailViewChange(false, false, null); } // Trigger refresh from parent if (onTaskSaved) { await onTaskSaved(); } }} /> </React.Suspense> ); } else { return ( <TaskDetailView task={selectedTask} onBack={() => setSelectedTask(null)} projectRoot={projectRoot} onNavigateToTask={(taskId) => { const targetTask = data.find(t => t.id === taskId); if (targetTask) { setSelectedTask(targetTask); } }} taskIndex={data.findIndex(t => t.id === selectedTask.id)} allTasks={data} onEdit={() => { setSelectedTask({ ...selectedTask, editMode: true }); }} /> ); } } // Bulk assign agents function const handleBulkAssignAgents = async () => { const selectedTaskIds = Array.from(selectedRows); if (selectedTaskIds.length === 0) return; // Show loading state setLoading(true); try { // Call API to assign agents using AI const response = await fetch('/api/ai-assign-agents', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ projectId: profileId, taskIds: selectedTaskIds }) }); // Check if response is JSON const contentType = response.headers.get("content-type"); if (!contentType || !contentType.includes("application/json")) { const text = await response.text(); console.error('Non-JSON response:', text); throw new Error('Server returned non-JSON response'); } const result = await response.json(); if (response.ok) { // Success! Show success message if (showToast) { showToast('success', `Successfully assigned agents to ${result.updatedCount} tasks using AI`); } // Refresh the task data to show updated agents if (onTaskSaved) { onTaskSaved(); } // Clear selection setSelectedRows(new Set()); setShowBulkActions(false); } else { // Handle error response if (result.error === 'OpenAI API key not configured') { // Show modal with setup instructions const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ` <div class="modal-content error-modal"> <h2>❌ OpenAI API Key Required</h2> <p>${result.message}</p> <div class="instructions"> ${result.instructions.map(instruction => { // Check if this is a path line and style it differently if (instruction.includes('/home/') || instruction.includes('C:\\\\')) { return `<p class="file-path">${instruction}</p>`; } return `<p>${instruction}</p>`; }).join('')} </div> <button class="primary-btn" onclick="this.closest('.modal-overlay').remove()">Close</button> </div> `; document.body.appendChild(modal); } else { // Show general error if (showToast) { showToast('error', result.error || 'Failed to assign agents'); } } } } catch (error) { console.error('Error assigning agents:', error); if (showToast) { showToast('error', 'Network error while assigning agents'); } } finally { setLoading(false); } }; // Otherwise, show the table return ( <> {showBulkActions && ( <div className="bulk-actions-bar"> <button className="bulk-action-button ai-assign" onClick={handleBulkAssignAgents} disabled={loading} > {loading ? '⏳ Processing...' : `🤖 AI Assign Agents (${selectedRows.size} tasks selected)`} </button> </div> )} <table className="table"> <thead> {table.getHeaderGroups().map(headerGroup => ( <tr key={headerGroup.id}> {headerGroup.headers.map(header => ( <th key={header.id} style={{ width: header.getSize() }} onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined} className={header.column.getCanSort() ? 'sortable' : ''} > {flexRender(header.column.columnDef.header, header.getContext())} {header.column.getIsSorted() && ( <span style={{ marginLeft: '8px' }}> {header.column.getIsSorted() === 'desc' ? '↓' : '↑'} </span> )} </th> ))} </tr> ))} </thead> <tbody> {table.getRowModel().rows.map(row => ( <tr key={row.id} className={`clickable-row ${row.original.status === 'in_progress' ? 'task-in-progress' : ''}`} onClick={() => setSelectedTask(row.original)} title={t('clickToViewTaskDetails')} > {row.getVisibleCells().map(cell => ( <td key={cell.id}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </td> ))} </tr> ))} </tbody> </table> <div className="pagination"> <div className="pagination-info"> {t('showing')} {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} {t('to')}{' '} {Math.min( (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, table.getFilteredRowModel().rows.length )}{' '} {t('of')} {table.getFilteredRowModel().rows.length} {t('tasks')} {globalFilter && ` (${t('filteredFrom')} ${data.length} ${t('total')})`} </div> <div className="pagination-controls"> <button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} > {'<<'} </button> <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > {'<'} </button> <span> {t('page')} {table.getState().pagination.pageIndex + 1} {t('of')}{' '} {table.getPageCount()} </span> <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > {'>'} </button> <button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} > {'>>'} </button> </div> </div> <AgentInfoModal agent={agentModalInfo.agent} isOpen={agentModalInfo.isOpen} onClose={() => setAgentModalInfo({ isOpen: false, agent: null, taskId: null })} availableAgents={availableAgents} onSelectAgent={async (agentName) => { // Find the task that triggered the modal if (agentModalInfo.taskId) { // Update saving state setSavingAgents(prev => ({ ...prev, [agentModalInfo.taskId]: true })); try { const response = await fetch(`/api/tasks/${profileId}/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ taskId: agentModalInfo.taskId, updates: { agent: agentName } }) }); if (response.ok) { // Refresh task data if (onTaskSaved) { await onTaskSaved(); } } else { console.error('Failed to update agent'); } } catch (err) { console.error('Error updating agent:', err); } finally { // Clear saving state setSavingAgents(prev => { const newState = { ...prev }; delete newState[agentModalInfo.taskId]; return newState; }); } } }} /> </> ); } export default TaskTable;

Latest Blog Posts

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/cjo4m06/mcp-shrimp-task-manager'

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