diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/routes/mesh.tsx | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/routes/mesh.tsx')
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 634 |
1 files changed, 634 insertions, 0 deletions
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx new file mode 100644 index 0000000..852ce58 --- /dev/null +++ b/makima/frontend/src/routes/mesh.tsx @@ -0,0 +1,634 @@ +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<TaskWithSubtasks | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]); + const [isStreaming, setIsStreaming] = useState(false); + // 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); + // View mode for the split panel layout + const [viewMode, setViewMode] = useState<ViewMode>("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<HTMLDivElement>(null); + // Track which task we've loaded output for to avoid stale saves + const loadedTaskIdRef = useRef<string | null>(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 ( + <div className="relative z-10 h-screen flex flex-col overflow-hidden"> + <Masthead showTicker={false} showNav /> + + <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col"> + {error && ( + <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0"> + {error} + </div> + )} + + {conflict?.hasConflict && ( + <div className="mb-4 p-3 border border-yellow-400/50 bg-yellow-400/10 text-yellow-400 font-mono text-sm shrink-0"> + <p>Version conflict detected. Please reload and try again.</p> + <button + onClick={clearConflict} + className="mt-2 px-3 py-1 border border-yellow-400/30 hover:border-yellow-400/50 text-xs uppercase" + > + Dismiss + </button> + </div> + )} + + {/* Main content area - conditional based on route */} + <div className="flex-1 flex flex-col min-h-0 overflow-hidden gap-4"> + {id && taskDetail ? ( + <> + {/* Header with connection status and view toggle */} + <div className="flex items-center justify-between shrink-0"> + <div className="flex items-center gap-2"> + <span + className={`w-2 h-2 rounded-full ${ + connected ? "bg-green-400" : "bg-yellow-400 animate-pulse" + }`} + /> + <span className="font-mono text-[10px] text-[#75aafc] uppercase"> + {connected ? "Connected" : "Connecting..."} + </span> + </div> + {/* View mode toggle */} + <button + onClick={cycleViewMode} + className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + View: {getViewModeLabel(viewMode)} + </button> + </div> + + {/* Split panel layout */} + <div + ref={containerRef} + className={`flex-1 flex min-h-0 overflow-hidden ${isResizing ? "select-none" : ""}`} + > + {/* Task detail panel */} + {(viewMode === "split" || viewMode === "task") && ( + <div + className="min-h-0 overflow-hidden" + style={{ + width: viewMode === "split" ? `${taskPanelPercent}%` : "100%", + flexShrink: 0, + }} + > + <TaskDetail + task={taskDetail} + loading={detailLoading} + onBack={handleBack} + onSave={handleSave} + onDelete={handleDelete} + onStart={handleStart} + onStop={handleStop} + onRestart={handleRestart} + onContinue={handleContinue} + onSelectSubtask={handleSelectTask} + onCreateSubtask={handleCreateSubtask} + onToggleSubtaskOutput={handleToggleSubtaskOutput} + viewingSubtaskId={viewingSubtaskId} + /> + </div> + )} + + {/* Resizable divider */} + {viewMode === "split" && ( + <div + className="w-1 shrink-0 cursor-col-resize bg-[rgba(117,170,252,0.15)] hover:bg-[rgba(117,170,252,0.35)] transition-colors group flex items-center justify-center" + onMouseDown={handleResizeStart} + > + <div className="w-0.5 h-8 bg-[rgba(117,170,252,0.3)] group-hover:bg-[rgba(117,170,252,0.5)] rounded-full" /> + </div> + )} + + {/* Output panel */} + {(viewMode === "split" || viewMode === "output") && ( + <div + className="panel min-h-0 overflow-hidden flex-1" + > + <TaskOutput + entries={taskOutputEntries} + isStreaming={isStreaming || taskDetail.status === "running"} + viewingSubtaskName={viewingSubtaskName} + onClearSubtaskView={viewingSubtaskId ? () => { + setViewingSubtaskId(null); + setViewingSubtaskName(null); + } : undefined} + onClear={() => { + setTaskOutputEntries([]); + if (activeOutputTaskId) { + clearPersistedOutput(activeOutputTaskId); + } + }} + taskId={activeOutputTaskId} + onUserInput={handleUserInput} + /> + </div> + )} + </div> + </> + ) : id && detailLoading ? ( + <div className="panel flex-1 flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ) : ( + <div className="flex-1 min-h-0 overflow-hidden"> + <TaskList + tasks={tasks} + loading={loading || creating} + onSelect={handleSelectTask} + onDelete={handleDelete} + onCreate={handleCreate} + /> + </div> + )} + + {/* Mesh Chat Input - always rendered to persist state across navigation */} + <div className="shrink-0"> + <UnifiedMeshChatInput + context={chatContext} + onUpdate={id ? handleTaskUpdatedFromCli : fetchTasks} + /> + </div> + </div> + </main> + </div> + ); +} |
