import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react"; import { useParams, useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { TaskList } from "../components/mesh/TaskList"; import { TaskDetail } from "../components/mesh/TaskDetail"; 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 } from "../lib/api"; import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api"; // View modes for the task detail page type ViewMode = "split" | "task" | "output"; // Minimum panel widths (in pixels) const MIN_TASK_WIDTH = 300; const MIN_OUTPUT_WIDTH = 200; // TODO: Store task output in database for resuming from any device. // Currently only persisted in localStorage which is device-specific. // LocalStorage key prefix for task output const STORAGE_KEY_PREFIX_OUTPUT = "makima-task-output-"; // Load persisted output from localStorage with deduplication function loadPersistedOutput(taskId: string): TaskOutputEvent[] { try { const stored = localStorage.getItem(STORAGE_KEY_PREFIX_OUTPUT + taskId); if (!stored) return []; const entries = JSON.parse(stored) as TaskOutputEvent[]; // Deduplicate consecutive identical entries (cleanup from previous bug) const deduplicated: TaskOutputEvent[] = []; for (const entry of entries) { const last = deduplicated[deduplicated.length - 1]; if ( !last || last.messageType !== entry.messageType || last.content !== entry.content || last.toolName !== entry.toolName ) { deduplicated.push(entry); } } // Save cleaned up version if we removed duplicates if (deduplicated.length !== entries.length) { savePersistedOutput(taskId, deduplicated); } return deduplicated; } catch { return []; } } // Save output to localStorage function savePersistedOutput(taskId: string, entries: TaskOutputEvent[]): void { try { localStorage.setItem(STORAGE_KEY_PREFIX_OUTPUT + taskId, JSON.stringify(entries)); } catch { // Ignore storage errors } } // Clear output from localStorage function clearPersistedOutput(taskId: string): void { try { localStorage.removeItem(STORAGE_KEY_PREFIX_OUTPUT + taskId); } catch { // Ignore storage errors } } export default function MeshPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, saveTask } = useTasks(); const [taskDetail, setTaskDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [creating, setCreating] = useState(false); const [taskOutputEntries, setTaskOutputEntries] = useState([]); const [isStreaming, setIsStreaming] = useState(false); // Track which subtask's output we're viewing (null = parent task) const [viewingSubtaskId, setViewingSubtaskId] = useState(null); const [viewingSubtaskName, setViewingSubtaskName] = useState(null); // View mode for the split panel layout const [viewMode, setViewMode] = useState("split"); // Width of the task panel as a percentage (0-100) const [taskPanelPercent, setTaskPanelPercent] = useState(66.67); // Track resizing state const [isResizing, setIsResizing] = useState(false); const containerRef = useRef(null); // Track which task we've loaded output for to avoid stale saves const loadedTaskIdRef = useRef(null); // Handle task update events from WebSocket const handleTaskUpdate = useCallback(async (event: TaskUpdateEvent) => { // Refresh task list if we're viewing the list if (!id) { fetchTasks(); return; } // Check if this update is for the current task or one of its subtasks const isCurrentTask = event.taskId === id; const isSubtask = taskDetail?.subtasks.some((st) => st.id === event.taskId); // Refresh task detail if the update is for current task or any subtask // This ensures subtask status changes (e.g., when orchestrator starts them) are reflected if (isCurrentTask || isSubtask) { const updated = await fetchTask(id); if (updated) { setTaskDetail(updated); } } // Update streaming state based on status for current task if (isCurrentTask) { setIsStreaming(event.status === "running"); } }, [id, fetchTask, fetchTasks, taskDetail?.subtasks]); // The task ID whose output we're currently viewing const activeOutputTaskId = viewingSubtaskId || id; // Handle task output events from WebSocket const handleTaskOutput = useCallback((event: TaskOutputEvent) => { // Only process output for the task we're currently viewing if (event.taskId === activeOutputTaskId) { setTaskOutputEntries((prev) => { // Deduplicate by checking if last entry is identical // This prevents duplicates from React StrictMode or WebSocket reconnects const lastEntry = prev[prev.length - 1]; if ( lastEntry && lastEntry.messageType === event.messageType && lastEntry.content === event.content && lastEntry.toolName === event.toolName ) { return prev; // Skip duplicate } const newEntries = [...prev, event]; // Persist to localStorage savePersistedOutput(event.taskId, newEntries); return newEntries; }); } }, [activeOutputTaskId]); // Handle user input sent to task - show immediately in output const handleUserInput = useCallback((message: string) => { if (!activeOutputTaskId) return; const userEntry: TaskOutputEvent = { taskId: activeOutputTaskId, messageType: "user_input", content: message, isPartial: false, }; setTaskOutputEntries((prev) => { const newEntries = [...prev, userEntry]; savePersistedOutput(activeOutputTaskId, newEntries); return newEntries; }); }, [activeOutputTaskId]); // Subscribe to task updates and output // When viewing a subtask's output, subscribe to that instead of the parent // Always subscribe to all updates so we see subtask status changes const { connected } = useTaskSubscription({ taskId: id || null, subscribeAll: true, // Always subscribe to all - needed to see subtask updates subscribeOutput: !!activeOutputTaskId, // Subscribe to output when viewing a task outputTaskId: activeOutputTaskId || undefined, // Which task's output to subscribe to onUpdate: handleTaskUpdate, onOutput: handleTaskOutput, }); // Load persisted output when task or viewed subtask changes useEffect(() => { if (activeOutputTaskId) { // First load from localStorage (instant, for local cache) const persisted = loadPersistedOutput(activeOutputTaskId); setTaskOutputEntries(persisted); loadedTaskIdRef.current = activeOutputTaskId; // Then fetch from API to get any output we missed // (e.g., subtask was running before we started viewing it) getTaskOutput(activeOutputTaskId) .then((response) => { if (response.entries.length > 0) { setTaskOutputEntries((prev) => { // API returns all historical entries in chronological order const apiEntries = response.entries.map(entry => ({ taskId: entry.taskId, messageType: entry.messageType, content: entry.content, toolName: entry.toolName, toolInput: entry.toolInput, isError: entry.isError, costUsd: entry.costUsd, durationMs: entry.durationMs, isPartial: false, })); // If localStorage is empty, just use API data if (prev.length === 0) { savePersistedOutput(activeOutputTaskId, apiEntries); return apiEntries; } // localStorage has user_input entries in correct positions - trust its order // Only append API entries that we don't already have locally const localKeys = new Set(prev.map(e => `${e.messageType}:${e.content}`)); const newFromApi = apiEntries.filter(e => !localKeys.has(`${e.messageType}:${e.content}`)); // Keep local order (has user_input in correct spots), append new API data const merged = [...prev, ...newFromApi]; savePersistedOutput(activeOutputTaskId, merged); return merged; }); } }) .catch((err) => { console.error("Failed to fetch task output:", err); }); } else { setTaskOutputEntries([]); loadedTaskIdRef.current = null; } setIsStreaming(false); }, [activeOutputTaskId]); // Reset subtask view when navigating to a different parent task useEffect(() => { setViewingSubtaskId(null); setViewingSubtaskName(null); }, [id]); // Toggle viewing a subtask's output (for running subtasks) const handleToggleSubtaskOutput = useCallback( (subtaskId: string, subtaskName: string) => { if (viewingSubtaskId === subtaskId) { // Already viewing this subtask, switch back to parent setViewingSubtaskId(null); setViewingSubtaskName(null); } else { // Switch to viewing this subtask's output setViewingSubtaskId(subtaskId); setViewingSubtaskName(subtaskName); } }, [viewingSubtaskId] ); // Load task detail when URL has an id useEffect(() => { if (id) { setDetailLoading(true); fetchTask(id).then((detail) => { setTaskDetail(detail); setDetailLoading(false); }); } else { setTaskDetail(null); } }, [id, fetchTask]); const handleSelectTask = useCallback( (taskId: string) => { navigate(`/mesh/${taskId}`); }, [navigate] ); const handleBack = useCallback(() => { // If viewing a subtask, go back to parent if (taskDetail?.parentTaskId) { navigate(`/mesh/${taskDetail.parentTaskId}`); } else { navigate("/mesh"); } }, [navigate, taskDetail]); const handleDelete = useCallback( async (taskId: string) => { if (confirm("Are you sure you want to delete this task?")) { const success = await removeTask(taskId); if (success && id === taskId) { // If deleting current task, go back if (taskDetail?.parentTaskId) { navigate(`/mesh/${taskDetail.parentTaskId}`); } else { navigate("/mesh"); } } } }, [removeTask, id, taskDetail, navigate] ); const handleStart = useCallback( async (taskId: string) => { try { const updated = await startTaskApi(taskId); setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); } catch (e) { console.error("Failed to start task:", e); alert(e instanceof Error ? e.message : "Failed to start task"); } }, [] ); const handleStop = useCallback( async (taskId: string) => { try { const updated = await stopTaskApi(taskId); setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); } catch (e) { console.error("Failed to stop task:", e); alert(e instanceof Error ? e.message : "Failed to stop task"); } }, [] ); const handleRestart = useCallback( async (taskId: string) => { try { // First stop the task await stopTaskApi(taskId); // Then start it again const updated = await startTaskApi(taskId); setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); } catch (e) { console.error("Failed to restart task:", e); alert(e instanceof Error ? e.message : "Failed to restart task"); } }, [] ); const handleContinue = useCallback( async (taskId: string) => { try { // Start the task again from terminal state const updated = await startTaskApi(taskId); setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); } catch (e) { console.error("Failed to continue task:", e); alert(e instanceof Error ? e.message : "Failed to continue task"); } }, [] ); const handleSave = useCallback( async (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: string) => { if (!taskDetail) return; const result = await editTask(taskId, { name, description: description || undefined, plan, targetRepoPath: targetRepoPath || undefined, completionAction: completionAction as import("../lib/api").CompletionAction | undefined, version: taskDetail.version, }); if (result) { setTaskDetail(result); } }, [editTask, taskDetail] ); const handleCreate = useCallback(async () => { if (creating) return; setCreating(true); try { const newTask = await saveTask({ name: `Task ${new Date().toLocaleDateString()}`, plan: "# Plan\n\nDescribe what this task should accomplish...", }); if (newTask) { navigate(`/mesh/${newTask.id}`); } } finally { setCreating(false); } }, [creating, saveTask, navigate]); const handleCreateSubtask = useCallback(async () => { if (!taskDetail || creating) return; setCreating(true); try { const newTask = await saveTask({ name: `Subtask of ${taskDetail.name}`, plan: "# Plan\n\nDescribe what this subtask should accomplish...", parentTaskId: taskDetail.id, }); if (newTask) { // Refresh current task to show new subtask const refreshed = await fetchTask(taskDetail.id); if (refreshed) { setTaskDetail(refreshed); } } } finally { setCreating(false); } }, [creating, saveTask, taskDetail, fetchTask]); // Callback when task is updated via CLI const handleTaskUpdatedFromCli = useCallback(async () => { if (id) { const updated = await fetchTask(id); if (updated) { setTaskDetail(updated); } } // Also refresh the task list fetchTasks(); }, [id, fetchTask, fetchTasks]); // Calculate chat context based on current view const chatContext: MeshChatContext = useMemo(() => { if (!id) { return { type: "mesh" }; } if (taskDetail?.parentTaskId) { return { type: "subtask", taskId: id, parentTaskId: taskDetail.parentTaskId }; } return { type: "task", taskId: id }; }, [id, taskDetail?.parentTaskId]); // Handle resizing of the split panel const handleResizeStart = useCallback((e: MouseEvent) => { e.preventDefault(); setIsResizing(true); }, []); useEffect(() => { if (!isResizing) return; const handleMouseMove = (e: globalThis.MouseEvent) => { if (!containerRef.current) return; const containerRect = containerRef.current.getBoundingClientRect(); const containerWidth = containerRect.width; const mouseX = e.clientX - containerRect.left; // Calculate percentage, respecting minimum widths const minTaskPercent = (MIN_TASK_WIDTH / containerWidth) * 100; const maxTaskPercent = ((containerWidth - MIN_OUTPUT_WIDTH) / containerWidth) * 100; const newPercent = Math.max(minTaskPercent, Math.min(maxTaskPercent, (mouseX / containerWidth) * 100)); setTaskPanelPercent(newPercent); }; const handleMouseUp = () => { setIsResizing(false); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [isResizing]); // Cycle through view modes const cycleViewMode = useCallback(() => { setViewMode((current) => { if (current === "split") return "task"; if (current === "task") return "output"; return "split"; }); }, []); // Get label for current view mode const getViewModeLabel = (mode: ViewMode): string => { switch (mode) { case "split": return "Split"; case "task": return "Task"; case "output": return "Output"; } }; return (
{error && (
{error}
)} {conflict?.hasConflict && (

Version conflict detected. Please reload and try again.

)} {/* Main content area - conditional based on route */}
{id && taskDetail ? ( <> {/* Header with connection status and view toggle */}
{connected ? "Connected" : "Connecting..."}
{/* View mode toggle */}
{/* Split panel layout */}
{/* Task detail panel */} {(viewMode === "split" || viewMode === "task") && (
)} {/* Resizable divider */} {viewMode === "split" && (
)} {/* Output panel */} {(viewMode === "split" || viewMode === "output") && (
{ setViewingSubtaskId(null); setViewingSubtaskName(null); } : undefined} onClear={() => { setTaskOutputEntries([]); if (activeOutputTaskId) { clearPersistedOutput(activeOutputTaskId); } }} taskId={activeOutputTaskId} onUserInput={handleUserInput} />
)}
) : id && detailLoading ? (
Loading...
) : (
)} {/* Mesh Chat Input - always rendered to persist state across navigation */}
); }