summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/mesh.tsx
blob: 67129f9019cead44617fefd05f75f434f7afa45a (plain) (tree)
1
2
3
4
5
6
7
8
9
10






                                                                                           
                                                                                       

                                                                                                               

                                                                                                                                                                                                                                          
                                                                   
                                                  
                                                                                


































































                                                                                      
                                                                                  
                                                                                                                                         







                                                                      







                                                                      



                                                                                            






                                                                 




                                                                                    










                                                                                               





                                                                                             


                                                                                    

                                                                                      











                                                                          



                                                                                
                   







                                                                              






































































































































































                                                                                                         



















                                                                                            




























                                                                                   

                                       
                                  






                                              
                                                   
            
                        









                                                                  
                                                         
                  
                              






                                          






                                    












































                                                                         
















                                                                                             




                                                                          
                                                                 



















                                                                                                                                    









                                                                            
                                            







                                                               
                                  
                                                










































                                                                                                 





                                                        


                                    


                    

                                                    
                         
                                

                      













                                                                             
                                      

                                                                                                          
                                                                       

                                                

                    
                                        



                         
                                                                                                                                                                  








                                              




                                    
         
 












































































                                                                                                             























                                                                                         








































































                                                                                                                                                                      

                                                                       
                                                                                           
                                             
                                                                                         
















                                                                                                                                                                                     
                                                                                  
                   














                                                                                  
                                                                                                                        
















                                                                      














                                                                                
                                         













                                                                                      

































                                                                                                                               


                                                                                 
                           

                                                                                                                                                                      
                     








                                                                                                                              

                             




                                                                                                  
                             




                                                                                                                                                                            
                       
                                       
                               





























                                                                                                                                                                                         

                                         




























                                                                                                                                                                           
 
                                                                   
                                                                                    
                                               



                                                                                                                              
                                                                                                                                                                             













                                                                                                     
                          
                    
 





































































































                                                                                                                                                                                 

                                                                                                            
                                              








                                                                          
                                                                                                    
                          
                          










                                                                                                                                                                                                     
                        
                      




                  


          
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 { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQuestion";
import { useTasks } from "../hooks/useTasks";
import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary, RepositoryHistoryEntry } from "../lib/api";
import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions } from "../lib/api";
import { DirectoryInput } from "../components/mesh/DirectoryInput";
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";

// 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 { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
  const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, hideTask, saveTask } = useTasks();
  const { pendingQuestions, submitAnswer } = useSupervisorQuestions();

  // Memoize pending question IDs for efficient lookup
  const pendingQuestionIds = useMemo(
    () => new Set(pendingQuestions.map(q => q.questionId)),
    [pendingQuestions]
  );

  // Filter contract_complete questions for the current task
  const contractCompleteQuestionsForTask = useMemo(
    () => pendingQuestions.filter(
      (q) => q.questionType === "contract_complete" && q.taskId === id
    ),
    [pendingQuestions, id]
  );

  // Handler for answering supervisor questions
  const handleAnswerQuestion = useCallback(async (questionId: string, response: string) => {
    await submitAnswer(questionId, response);
  }, [submitAnswer]);

  // Redirect to login if not authenticated
  useEffect(() => {
    if (!authLoading && isAuthConfigured && !isAuthenticated) {
      navigate("/login");
    }
  }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
  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);
  // Contract selection modal state
  const [showContractModal, setShowContractModal] = useState(false);
  const [contracts, setContracts] = useState<ContractSummary[]>([]);
  const [contractsLoading, setContractsLoading] = useState(false);
  // Task creation modal (step 2)
  const [modalStep, setModalStep] = useState<1 | 2>(1);
  const [selectedContract, setSelectedContract] = useState<ContractWithRelations | null>(null);
  const [daemonDirectories, setDaemonDirectories] = useState<DaemonDirectory[]>([]);
  const [newTaskName, setNewTaskName] = useState("");
  const [newTaskRepoUrl, setNewTaskRepoUrl] = useState<string | null>(null);
  const [newTaskTargetPath, setNewTaskTargetPath] = useState("");
  // Standalone task repository selection state
  const [standaloneRepoType, setStandaloneRepoType] = useState<"remote" | "local">("remote");
  const [standaloneRepoUrl, setStandaloneRepoUrl] = useState("");
  const [standaloneRepoPath, setStandaloneRepoPath] = useState("");
  const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]);
  const [showRepoSuggestions, setShowRepoSuggestions] = 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);
  // 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)
  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) => {
    // Always refresh task list when a new task is created (e.g., by supervisor)
    // This ensures newly spawned tasks appear in the sidebar immediately
    const isNewTask = event.updatedFields.includes("created");
    if (isNewTask) {
      fetchTasks();
    }

    // Refresh task list if we're viewing the list (no specific task selected)
    if (!id) {
      if (!isNewTask) {
        // Only fetch if we didn't already fetch for new task creation
        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]);

  // 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]);

  // Fetch repository suggestions when standalone task modal is open
  useEffect(() => {
    if (showContractModal && modalStep === 2 && !selectedContract) {
      getRepositorySuggestions(standaloneRepoType, undefined, 10)
        .then((res) => {
          setRepoSuggestions(res.entries);
          setShowRepoSuggestions(res.entries.length > 0);
        })
        .catch(() => {
          setRepoSuggestions([]);
          setShowRepoSuggestions(false);
        });
    } else if (!showContractModal) {
      setRepoSuggestions([]);
      setShowRepoSuggestions(false);
    }
  }, [showContractModal, modalStep, selectedContract, standaloneRepoType]);

  // Apply a repository suggestion
  const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => {
    if (suggestion.repositoryUrl) {
      setStandaloneRepoUrl(suggestion.repositoryUrl);
    }
    if (suggestion.localPath) {
      setStandaloneRepoPath(suggestion.localPath);
    }
    setShowRepoSuggestions(false);
  }, []);

  const handleSelectTask = useCallback(
    (taskId: string) => {
      navigate(`/exec/${taskId}`);
    },
    [navigate]
  );

  const handleBack = useCallback(() => {
    // If viewing a subtask, go back to parent
    if (taskDetail?.parentTaskId) {
      navigate(`/exec/${taskDetail.parentTaskId}`);
    } else {
      navigate("/exec");
    }
  }, [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(`/exec/${taskDetail.parentTaskId}`);
          } else {
            navigate("/exec");
          }
        }
      }
    },
    [removeTask, id, taskDetail, navigate]
  );

  const handleDismiss = useCallback(
    async (taskId: string) => {
      await hideTask(taskId);
    },
    [hideTask]
  );

  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 {
        // Check if this is a supervisor task - use resumeSupervisor API instead
        if (taskDetail?.isSupervisor && taskDetail?.contractId) {
          const result = await resumeSupervisor(taskDetail.contractId, {
            resumeMode: "continue",
          });
          console.log(`[Mesh] Supervisor resumed, daemon: ${result.daemonId}`);
          // Refresh task detail to get updated state
          const updated = await fetchTask(taskId);
          if (updated) {
            setTaskDetail(updated);
          }
        } else {
          // Continue regular task with conversation context from previous run
          const result = await continueTaskApi(taskId);
          console.log(`[Mesh] Task continued with ${result.contextEntries} context entries`);
          setTaskDetail((prev) => prev ? { ...prev, ...result.task } : prev);
        }
      } catch (e) {
        console.error("Failed to continue task:", e);
        alert(e instanceof Error ? e.message : "Failed to continue task");
      }
    },
    [taskDetail?.isSupervisor, taskDetail?.contractId, fetchTask]
  );

  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 handleBranch = useCallback(
    async (taskId: string, message: string, name?: string) => {
      try {
        const result = await branchTask(taskId, {
          message,
          name,
          includeConversation: true,
        });
        console.log(`[Mesh] Task branched, new task ID: ${result.task.id}`);
        // Navigate to the new branched task
        navigate(`/exec/${result.task.id}`);
      } catch (e) {
        console.error("Failed to branch task:", e);
        throw e; // Re-throw so the modal can display the error
      }
    },
    [navigate]
  );

  // Open contract selection modal
  const handleCreate = useCallback(async () => {
    if (creating || contractsLoading) return;
    setContractsLoading(true);
    try {
      const [contractsResponse, directoriesResponse] = await Promise.all([
        listContracts(),
        getDaemonDirectories().catch(() => ({ directories: [] })),
      ]);
      setContracts(contractsResponse.contracts);
      setDaemonDirectories(directoriesResponse.directories);
      setModalStep(1);
      setSelectedContract(null);
      setNewTaskName("");
      setNewTaskRepoUrl(null);
      setNewTaskTargetPath("");
      setShowContractModal(true);
    } catch (e) {
      console.error("Failed to load contracts:", e);
    } finally {
      setContractsLoading(false);
    }
  }, [creating, contractsLoading]);

  // Handle contract selection and move to step 2
  const handleSelectContract = useCallback(async (contractSummary: ContractSummary) => {
    try {
      const contract = await getContract(contractSummary.id);
      setSelectedContract(contract);
      setNewTaskName(`Task for ${contract.name}`);
      // Pre-select primary repository if available
      const primaryRepo = contract.repositories.find((r) => r.isPrimary && r.status === "ready");
      if (primaryRepo) {
        setNewTaskRepoUrl(primaryRepo.repositoryUrl);
      } else {
        // Otherwise select first ready repository
        const firstReady = contract.repositories.find((r) => r.status === "ready");
        setNewTaskRepoUrl(firstReady?.repositoryUrl || null);
      }
      setModalStep(2);
    } catch (e) {
      console.error("Failed to load contract details:", e);
    }
  }, []);

  // Handle creating a standalone task (no contract)
  const handleCreateStandaloneTask = useCallback(() => {
    setSelectedContract(null);
    setNewTaskName("Standalone Task");
    setNewTaskRepoUrl(null);
    setNewTaskTargetPath("");
    setStandaloneRepoType("remote");
    setStandaloneRepoUrl("");
    setStandaloneRepoPath("");
    setModalStep(2);
  }, []);

  // Create task with configured options
  const handleCreateTask = useCallback(async () => {
    if (creating) return;
    setShowContractModal(false);
    setCreating(true);
    try {
      // For standalone tasks, use the standalone repo URL/path based on type
      let repoUrl = newTaskRepoUrl;
      let targetPath = newTaskTargetPath;

      if (!selectedContract) {
        // Standalone task - use the standalone repo fields
        if (standaloneRepoType === "remote" && standaloneRepoUrl) {
          repoUrl = standaloneRepoUrl;
        } else if (standaloneRepoType === "local" && standaloneRepoPath) {
          // For local paths, use targetRepoPath instead
          targetPath = standaloneRepoPath;
        }
      }

      const newTask = await saveTask({
        contractId: selectedContract?.id,
        name: newTaskName || (selectedContract ? `Task for ${selectedContract.name}` : "Standalone Task"),
        plan: "# Plan\n\nDescribe what this task should accomplish...",
        repositoryUrl: repoUrl || undefined,
        targetRepoPath: targetPath || undefined,
      });
      if (newTask) {
        navigate(`/exec/${newTask.id}`);
      }
    } finally {
      setCreating(false);
    }
  }, [creating, saveTask, navigate, selectedContract, newTaskName, newTaskRepoUrl, newTaskTargetPath, standaloneRepoType, standaloneRepoUrl, standaloneRepoPath]);

  // Close modal and reset state
  const handleCloseModal = useCallback(() => {
    setShowContractModal(false);
    setModalStep(1);
    setSelectedContract(null);
    setNewTaskName("");
    setNewTaskRepoUrl(null);
    setNewTaskTargetPath("");
    setStandaloneRepoType("remote");
    setStandaloneRepoUrl("");
    setStandaloneRepoPath("");
    setRepoSuggestions([]);
    setShowRepoSuggestions(false);
  }, []);

  // 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";
    }
  };

  // Show loading state while checking auth
  if (authLoading) {
    return (
      <div className="relative z-10 h-screen flex flex-col overflow-hidden">
        <Masthead showTicker={false} showNav />
        <main className="flex-1 flex items-center justify-center">
          <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
        </main>
      </div>
    );
  }

  // Don't render content if not authenticated (redirect will happen via useEffect)
  if (isAuthConfigured && !isAuthenticated) {
    return (
      <div className="relative z-10 h-screen flex flex-col overflow-hidden">
        <Masthead showTicker={false} showNav />
        <main className="flex-1 flex items-center justify-center">
          <div className="font-mono text-[#9bc3ff] text-sm">Redirecting to login...</div>
        </main>
      </div>
    );
  }

  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}
                      onToggleSubtaskOutput={handleToggleSubtaskOutput}
                      viewingSubtaskId={viewingSubtaskId}
                      onViewContract={(contractId) => navigate(`/contracts/${contractId}`)}
                      onBranch={handleBranch}
                      contractTasks={taskDetail.isSupervisor ? contractTasks : undefined}
                    />
                  </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 flex flex-col"
                  >
                    {/* Contract complete questions - shown prominently at top */}
                    {contractCompleteQuestionsForTask.length > 0 && (
                      <div className="shrink-0 px-3 pt-3">
                        {contractCompleteQuestionsForTask.map((question) => (
                          <ContractCompleteQuestion
                            key={question.questionId}
                            question={question}
                            onAnswer={handleAnswerQuestion}
                          />
                        ))}
                      </div>
                    )}
                    <div className="flex-1 min-h-0 overflow-hidden">
                      <TaskOutput
                        entries={taskOutputEntries}
                        isStreaming={isStreaming || taskDetail.status === "running" || taskDetail.status === "starting"}
                        viewingSubtaskName={viewingSubtaskName}
                        onClearSubtaskView={viewingSubtaskId ? () => {
                          setViewingSubtaskId(null);
                          setViewingSubtaskName(null);
                        } : undefined}
                        onClear={() => {
                          setTaskOutputEntries([]);
                          if (activeOutputTaskId) {
                            clearPersistedOutput(activeOutputTaskId);
                          }
                        }}
                        taskId={activeOutputTaskId}
                        onUserInput={handleUserInput}
                        pendingQuestionIds={pendingQuestionIds}
                        onAnswerQuestion={handleAnswerQuestion}
                      />
                    </div>
                  </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}
                onDismiss={handleDismiss}
                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>

      {/* Task Creation Modal (Two Steps) */}
      {showContractModal && (
        <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
          <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4 max-h-[80vh] flex flex-col">
            <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center">
              <div className="flex items-center gap-2">
                {modalStep === 2 && (
                  <button
                    onClick={() => setModalStep(1)}
                    className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
                    title="Back to contract selection"
                  >
                    <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
                    </svg>
                  </button>
                )}
                <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">
                  {modalStep === 1 ? "Select Contract" : "Configure Task"}
                </h2>
              </div>
              <button
                onClick={handleCloseModal}
                className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
              >
                <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                </svg>
              </button>
            </div>
            <div className="p-4 overflow-y-auto flex-1">
              {modalStep === 1 ? (
                // Step 1: Select Contract
                <div className="space-y-4">
                  {/* Standalone task option */}
                  <div className="pb-3 border-b border-[rgba(117,170,252,0.25)]">
                    <button
                      onClick={handleCreateStandaloneTask}
                      className="w-full text-left p-3 border border-[rgba(117,170,252,0.25)] bg-[#0a1525] hover:border-[#3f6fb3] hover:bg-[#0d1b2d] transition-colors"
                    >
                      <div className="flex items-center gap-2">
                        <svg className="w-4 h-4 text-[#9bc3ff]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
                        </svg>
                        <span className="text-[#9bc3ff] font-mono text-xs font-medium">Create Standalone Task</span>
                      </div>
                      <p className="text-[10px] font-mono text-[#7788aa] mt-1 ml-6">
                        Quick task without a contract. Good for one-off tasks.
                      </p>
                    </button>
                  </div>

                  {/* Contract selection */}
                  {contracts.length === 0 ? (
                    <div className="text-center py-4">
                      <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found.</p>
                      <button
                        onClick={() => {
                          handleCloseModal();
                          navigate("/contracts");
                        }}
                        className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors"
                      >
                        Create Contract
                      </button>
                    </div>
                  ) : (
                    <div className="space-y-2">
                      <div className="text-[10px] font-mono uppercase tracking-wide text-[#7788aa] px-1">
                        Or select a contract:
                      </div>
                      {contracts.map((contract) => (
                        <button
                          key={contract.id}
                          onClick={() => handleSelectContract(contract)}
                          className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors"
                        >
                          <div className="flex items-center justify-between">
                            <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span>
                            <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]">
                              {contract.phase}
                            </span>
                          </div>
                          {contract.description && (
                            <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p>
                          )}
                          <div className="flex gap-3 mt-2 text-[10px] font-mono text-[#556677]">
                            <span>{contract.taskCount} tasks</span>
                            <span>{contract.repositoryCount} repos</span>
                          </div>
                        </button>
                      ))}
                    </div>
                  )}
                </div>
              ) : (
                // Step 2: Configure Task
                <div className="space-y-4">
                  {/* Context badge */}
                  <div className="flex items-center gap-2 text-xs font-mono text-[#7788aa]">
                    {selectedContract ? (
                      <>
                        <span>Contract:</span>
                        <span className="text-[#9bc3ff]">{selectedContract.name}</span>
                      </>
                    ) : (
                      <>
                        <svg className="w-4 h-4 text-[#9bc3ff]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
                        </svg>
                        <span className="text-[#9bc3ff]">Standalone Task</span>
                      </>
                    )}
                  </div>

                  {/* Task name */}
                  <div className="space-y-1">
                    <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Task Name</label>
                    <input
                      type="text"
                      value={newTaskName}
                      onChange={(e) => setNewTaskName(e.target.value)}
                      className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
                      placeholder="Task name"
                    />
                  </div>

                  {/* Repository selection - for contract tasks */}
                  {selectedContract && selectedContract.repositories.length > 0 && (
                    <div className="space-y-1">
                      <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Repository</label>
                      <select
                        value={newTaskRepoUrl || ""}
                        onChange={(e) => setNewTaskRepoUrl(e.target.value || null)}
                        className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
                      >
                        <option value="">No repository</option>
                        {selectedContract.repositories
                          .filter((r) => r.status === "ready")
                          .map((repo) => (
                            <option key={repo.id} value={repo.repositoryUrl || repo.localPath || ""}>
                              {repo.name}
                              {repo.isPrimary && " (primary)"}
                            </option>
                          ))}
                      </select>
                      <p className="text-[10px] font-mono text-[#556677]">
                        The repository this task will work on.
                      </p>
                    </div>
                  )}

                  {/* Repository selection - for standalone tasks */}
                  {!selectedContract && (
                    <div className="space-y-3">
                      {/* Repository type selector */}
                      <div className="space-y-1">
                        <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Repository Type (optional)</label>
                        <div className="flex gap-2">
                          <button
                            type="button"
                            onClick={() => setStandaloneRepoType("remote")}
                            className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
                              standaloneRepoType === "remote"
                                ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
                                : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
                            }`}
                          >
                            Remote
                          </button>
                          <button
                            type="button"
                            onClick={() => setStandaloneRepoType("local")}
                            className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
                              standaloneRepoType === "local"
                                ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
                                : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
                            }`}
                          >
                            Local
                          </button>
                        </div>
                      </div>

                      {/* Repository suggestions */}
                      {showRepoSuggestions && repoSuggestions.length > 0 && (
                        <div className="space-y-1">
                          <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
                            Recent Repositories
                          </label>
                          <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto">
                            {repoSuggestions.map((suggestion) => (
                              <button
                                key={suggestion.id}
                                type="button"
                                onClick={() => applyRepoSuggestion(suggestion)}
                                className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
                              >
                                <div className="flex items-center justify-between">
                                  <span className="text-[#9bc3ff] truncate">{suggestion.name}</span>
                                  <span className="text-[10px] text-[#556677] ml-2">
                                    {suggestion.useCount}×
                                  </span>
                                </div>
                                <div className="text-[10px] text-[#556677] truncate">
                                  {standaloneRepoType === "local" ? suggestion.localPath : suggestion.repositoryUrl}
                                </div>
                              </button>
                            ))}
                          </div>
                        </div>
                      )}

                      {/* Repository URL (for remote) */}
                      {standaloneRepoType === "remote" && (
                        <div className="space-y-1">
                          <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
                            Repository URL
                          </label>
                          <input
                            type="text"
                            value={standaloneRepoUrl}
                            onChange={(e) => setStandaloneRepoUrl(e.target.value)}
                            placeholder="https://github.com/user/repo.git"
                            className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
                          />
                          <p className="text-[10px] font-mono text-[#556677]">
                            The remote repository this task will clone and work on.
                          </p>
                        </div>
                      )}

                      {/* Repository path (for local) */}
                      {standaloneRepoType === "local" && (
                        <div className="space-y-1">
                          <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
                            Local Path
                          </label>
                          <DirectoryInput
                            value={standaloneRepoPath}
                            onChange={setStandaloneRepoPath}
                            suggestions={daemonDirectories}
                            placeholder="/path/to/your/local/repo"
                          />
                          <p className="text-[10px] font-mono text-[#556677]">
                            The local directory this task will work in.
                          </p>
                        </div>
                      )}
                    </div>
                  )}

                  {/* Target repo path with DirectoryInput - only for contract tasks when repo is selected */}
                  {selectedContract && newTaskRepoUrl && (
                    <div className="space-y-1">
                      <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
                        Target Repository Path
                      </label>
                      <DirectoryInput
                        value={newTaskTargetPath}
                        onChange={setNewTaskTargetPath}
                        suggestions={daemonDirectories}
                        placeholder="/path/to/your/local/repo"
                        repoUrl={newTaskRepoUrl || undefined}
                      />
                      <p className="text-[10px] font-mono text-[#556677]">
                        Path where the task will push/merge changes. Leave empty to configure later.
                      </p>
                    </div>
                  )}

                  {/* Create button */}
                  <div className="pt-2">
                    <button
                      onClick={handleCreateTask}
                      disabled={creating}
                      className="w-full px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] disabled:opacity-50 transition-colors"
                    >
                      {creating ? "Creating..." : "Create Task"}
                    </button>
                  </div>
                </div>
              )}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}