diff options
| author | soryu <soryu@soryu.co> | 2026-01-18 18:02:08 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-18 18:02:08 +0000 |
| commit | e0da93a20a965125ba4cbb46e3e0e179f06c2a08 (patch) | |
| tree | 5d127394e1dfa921c5d09fe8f10d716f6548d168 | |
| parent | 869f21ee2efaefed6a5aa4fbd417c25df8dec02a (diff) | |
| download | soryu-e0da93a20a965125ba4cbb46e3e0e179f06c2a08.tar.gz soryu-e0da93a20a965125ba4cbb46e3e0e179f06c2a08.zip | |
Improve Mesh Tab: Organize by contract with status filter (#5)
* Add status filter toggle to Mesh Tab TaskList component
Add a filter toggle at the top of the TaskList that allows filtering by
contract status (All, Active, Completed, Archive) with Active as the default.
Changes:
- Backend: Add contract_status field to TaskSummary struct in models.rs
- Backend: Update all SQL queries returning TaskSummary to include
c.status as contract_status from the contracts table join
- Frontend: Add contractStatus to TaskSummary TypeScript type
- Frontend: Add useState for statusFilter with 'active' as default
- Frontend: Add filter button group in header area next to '+ New Task'
- Frontend: Update groupedTasks useMemo to filter based on contract status
- Frontend: Update empty state message to reflect current filter
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* feat(mesh): show all contract tasks for supervisor tasks
When viewing a supervisor task (task.isSupervisor === true), the
TaskDetail component now shows all tasks in the contract instead
of showing the subtasks tree.
Changes:
- Add contractTasks prop to TaskDetailProps for passing contract tasks
- Add displayTasks computed value that uses contractTasks for supervisors
- Change section header from "Subtasks" to "Contract Tasks" for supervisors
- Hide the "+ Add Subtask" button for supervisor tasks
- Update empty state message for supervisors
- Fetch contract tasks in mesh.tsx when viewing a supervisor task
- Filter out the supervisor itself from the contract tasks list
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* Task completion checkpoint
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskDetail.tsx | 43 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskList.tsx | 69 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 2 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 25 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 3 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 7 |
6 files changed, 120 insertions, 29 deletions
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index 8e853e7..efe26a8 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -32,6 +32,8 @@ interface TaskDetailProps { onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>; onAutoMerge?: () => Promise<void>; fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; + /** For supervisor tasks: all tasks in the contract (excluding the supervisor itself) */ + contractTasks?: TaskSummary[]; } function formatDate(dateStr: string): string { @@ -114,6 +116,7 @@ export function TaskDetail({ onCreatePR, onAutoMerge, fetchSubtasks, + contractTasks, }: TaskDetailProps) { const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(task.name); @@ -149,10 +152,18 @@ export function TaskDetail({ // Show continue for supervisors (always) or terminal states for other tasks const canContinue = isSupervisor || isTaskTerminal; - // Calculate subtask statistics + // Determine which tasks to show: for supervisors, show contractTasks; for regular tasks, show subtasks + const displayTasks = useMemo(() => { + if (isSupervisor && contractTasks) { + return contractTasks; + } + return task.subtasks; + }, [isSupervisor, contractTasks, task.subtasks]); + + // Calculate task statistics for progress bar const subtaskStats = useMemo( - () => calculateTreeStats(task.subtasks), - [task.subtasks] + () => calculateTreeStats(displayTasks), + [displayTasks] ); // Check if task can create PR @@ -645,14 +656,14 @@ export function TaskDetail({ </div> )} - {/* Subtasks */} + {/* Subtasks / Contract Tasks (for supervisors) */} <div className="space-y-2"> <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> - Subtasks ({task.subtasks.length}) + {isSupervisor ? `Contract Tasks (${displayTasks.length})` : `Subtasks (${displayTasks.length})`} </div> - {task.subtasks.length > 0 && ( + {displayTasks.length > 0 && ( <button onClick={() => setUseTreeView(!useTreeView)} className="font-mono text-[9px] text-[#555] hover:text-[#75aafc]" @@ -661,41 +672,41 @@ export function TaskDetail({ </button> )} </div> - {/* Disable adding subtasks at max depth (2 = sub-subtask, cannot have children) */} - {task.depth < 2 ? ( + {/* Disable adding subtasks for supervisors and at max depth (2 = sub-subtask, cannot have children) */} + {!isSupervisor && task.depth < 2 ? ( <button onClick={onCreateSubtask} className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" > + Add Subtask </button> - ) : ( + ) : !isSupervisor ? ( <span className="px-2 py-1 font-mono text-[10px] text-[#555] border border-[#333]" title="Maximum depth reached"> Max depth </span> - )} + ) : null} </div> - {/* Progress bar for subtasks */} - {task.subtasks.length > 0 && ( + {/* Progress bar for tasks */} + {displayTasks.length > 0 && ( <SubtaskProgressBar stats={subtaskStats} /> )} - {task.subtasks.length === 0 ? ( + {displayTasks.length === 0 ? ( <div className="text-[#555] font-mono text-xs py-4 text-center"> - No subtasks yet + {isSupervisor ? "No tasks in contract yet" : "No subtasks yet"} </div> ) : useTreeView ? ( <div className="border border-[rgba(117,170,252,0.15)]"> <SubtaskTree - subtasks={task.subtasks} + subtasks={displayTasks} onSelect={onSelectSubtask} fetchSubtasks={fetchSubtasks} /> </div> ) : ( <div className="divide-y divide-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.15)]"> - {task.subtasks.map((subtask: TaskSummary) => { + {displayTasks.map((subtask: TaskSummary) => { const isRunning = subtask.status === "running" || subtask.status === "initializing" || subtask.status === "starting"; const isViewingOutput = viewingSubtaskId === subtask.id; const isExpanded = expandedSubtaskId === subtask.id; diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx index d013782..f829b29 100644 --- a/makima/frontend/src/components/mesh/TaskList.tsx +++ b/makima/frontend/src/components/mesh/TaskList.tsx @@ -1,5 +1,5 @@ -import { useMemo } from "react"; -import type { TaskSummary, TaskStatus, ContractPhase } from "../../lib/api"; +import { useMemo, useState } from "react"; +import type { TaskSummary, TaskStatus, ContractPhase, ContractStatus } from "../../lib/api"; interface TaskListProps { tasks: TaskSummary[]; @@ -13,9 +13,12 @@ interface GroupedTasks { contractId: string | null; contractName: string | null; contractPhase: ContractPhase | null; + contractStatus: ContractStatus | null; tasks: TaskSummary[]; } +type StatusFilter = 'all' | 'active' | 'completed' | 'archived'; + function formatDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleDateString("en-US", { @@ -87,21 +90,36 @@ export function TaskList({ onDelete, onCreate, }: TaskListProps) { - // Group tasks by contract + // Filter state - default to 'active' to show only active contracts + const [statusFilter, setStatusFilter] = useState<StatusFilter>('active'); + + // Group tasks by contract and filter by status const groupedTasks = useMemo(() => { // Separate root tasks (no parent) from subtasks const rootTasks = tasks.filter((t) => !t.parentTaskId); + // Filter tasks based on contract status + const filteredTasks = statusFilter === 'all' + ? rootTasks + : rootTasks.filter((task) => { + // Tasks without a contract go in 'all' only, or treat them as 'active' + if (!task.contractId) { + return statusFilter === 'active'; + } + return task.contractStatus === statusFilter; + }); + // Group by contractId const groups = new Map<string | null, GroupedTasks>(); - for (const task of rootTasks) { + for (const task of filteredTasks) { const key = task.contractId; if (!groups.has(key)) { groups.set(key, { contractId: task.contractId, contractName: task.contractName, contractPhase: task.contractPhase, + contractStatus: task.contractStatus, tasks: [], }); } @@ -132,7 +150,7 @@ export function TaskList({ }); return sorted; - }, [tasks]); + }, [tasks, statusFilter]); if (loading) { return ( @@ -144,24 +162,51 @@ export function TaskList({ const totalTasks = groupedTasks.reduce((sum, g) => sum + g.tasks.length, 0); + const filterOptions: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'active', label: 'Active' }, + { value: 'completed', label: 'Completed' }, + { value: 'archived', label: 'Archive' }, + ]; + return ( <div className="panel h-full flex flex-col"> <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]"> <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> MESH//TASKS </div> - <button - onClick={onCreate} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase" - > - + New Task - </button> + <div className="flex items-center gap-2"> + {/* Status filter toggle */} + <div className="flex border border-[rgba(117,170,252,0.25)]"> + {filterOptions.map((option) => ( + <button + key={option.value} + onClick={() => setStatusFilter(option.value)} + className={`px-2 py-1 font-mono text-[10px] uppercase transition-colors ${ + statusFilter === option.value + ? 'bg-[rgba(117,170,252,0.2)] text-[#dbe7ff] border-r border-[rgba(117,170,252,0.25)] last:border-r-0' + : 'text-[#8b949e] hover:text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)] border-r border-[rgba(117,170,252,0.25)] last:border-r-0' + }`} + > + {option.label} + </button> + ))} + </div> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase" + > + + New Task + </button> + </div> </div> <div className="flex-1 overflow-y-auto"> {totalTasks === 0 ? ( <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> - No tasks yet. Create one to start orchestrating Claude Code instances. + {statusFilter === 'all' + ? 'No tasks yet. Create one to start orchestrating Claude Code instances.' + : `No ${statusFilter} tasks found.`} </div> ) : ( <div> diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 9b725bc..4652347 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -532,6 +532,8 @@ export interface TaskSummary { contractName: string | null; /** Contract phase (joined from contracts table) */ contractPhase: ContractPhase | null; + /** Contract status (joined from contracts table) */ + contractStatus: ContractStatus | null; parentTaskId: string | null; depth: number; name: string; diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index cc09bca..a8d3574 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -7,7 +7,7 @@ import { TaskOutput } from "../components/mesh/TaskOutput"; import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput"; import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; -import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory } from "../lib/api"; +import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary } from "../lib/api"; import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor } from "../lib/api"; import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; @@ -119,6 +119,8 @@ export default function MeshPage() { // Track which subtask's output we're viewing (null = parent task) const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null); const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null); + // For supervisor tasks: all tasks in the contract (excluding the supervisor itself) + const [contractTasks, setContractTasks] = useState<TaskSummary[]>([]); // View mode for the split panel layout const [viewMode, setViewMode] = useState<ViewMode>("split"); // Width of the task panel as a percentage (0-100) @@ -309,6 +311,26 @@ export default function MeshPage() { } }, [id, fetchTask]); + // For supervisor tasks: fetch all tasks in the contract (excluding the supervisor itself) + useEffect(() => { + if (taskDetail?.isSupervisor && taskDetail.contractId) { + getContract(taskDetail.contractId) + .then((contract) => { + // Filter out the supervisor task itself + const tasksExcludingSupervisor = contract.tasks.filter( + (t) => t.id !== taskDetail.id + ); + setContractTasks(tasksExcludingSupervisor); + }) + .catch((err) => { + console.error("Failed to fetch contract tasks for supervisor:", err); + setContractTasks([]); + }); + } else { + setContractTasks([]); + } + }, [taskDetail?.isSupervisor, taskDetail?.contractId, taskDetail?.id]); + const handleSelectTask = useCallback( (taskId: string) => { navigate(`/mesh/${taskId}`); @@ -711,6 +733,7 @@ export default function MeshPage() { onToggleSubtaskOutput={handleToggleSubtaskOutput} viewingSubtaskId={viewingSubtaskId} onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} + contractTasks={taskDetail.isSupervisor ? contractTasks : undefined} /> </div> )} diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 99c8b8e..3e6997f 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -544,6 +544,8 @@ pub struct TaskSummary { pub contract_name: Option<String>, /// Contract phase (joined from contracts table) pub contract_phase: Option<String>, + /// Contract status (joined from contracts table): 'active', 'completed', 'archived' + pub contract_status: Option<String>, pub parent_task_id: Option<Uuid>, /// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max) pub depth: i32, @@ -568,6 +570,7 @@ impl From<Task> for TaskSummary { contract_id: task.contract_id, contract_name: None, // Not available from Task directly contract_phase: None, // Not available from Task directly + contract_status: None, // Not available from Task directly parent_task_id: task.parent_task_id, depth: task.depth, name: task.name, diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 3d1efd1..36e6bc1 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -735,6 +735,7 @@ pub async fn list_tasks(pool: &PgPool) -> Result<Vec<TaskSummary>, sqlx::Error> r#" SELECT t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, t.parent_task_id, t.depth, t.name, t.status, t.priority, t.progress_summary, (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, @@ -755,6 +756,7 @@ pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSum r#" SELECT t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, t.parent_task_id, t.depth, t.name, t.status, t.priority, t.progress_summary, (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, @@ -1129,6 +1131,7 @@ pub async fn list_tasks_for_owner( r#" SELECT t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, t.parent_task_id, t.depth, t.name, t.status, t.priority, t.progress_summary, (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, @@ -1154,6 +1157,7 @@ pub async fn list_subtasks_for_owner( r#" SELECT t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, t.parent_task_id, t.depth, t.name, t.status, t.priority, t.progress_summary, (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, @@ -1671,6 +1675,7 @@ pub async fn list_sibling_tasks( r#" SELECT t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, t.parent_task_id, t.depth, t.name, t.status, t.priority, t.progress_summary, (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, @@ -1692,6 +1697,7 @@ pub async fn list_sibling_tasks( r#" SELECT t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, t.parent_task_id, t.depth, t.name, t.status, t.priority, t.progress_summary, (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, @@ -2669,6 +2675,7 @@ pub async fn list_tasks_in_contract( r#" SELECT t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, t.parent_task_id, t.depth, t.name, t.status, t.priority, t.progress_summary, (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, |
