summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/mesh.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/mesh.tsx')
-rw-r--r--makima/frontend/src/routes/mesh.tsx634
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>
+ );
+}