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 } from "../lib/api";
import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask } 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, 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("");
// 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) => {
// 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) => {
// For auth_required, only allow one per task (replace existing)
if (event.messageType === "auth_required") {
const hasExisting = prev.some(e => e.messageType === "auth_required");
if (hasExisting) {
return prev; // Skip duplicate auth_required
}
}
// 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]);
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 {
// 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(`/mesh/${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);
}
}, []);
// Create task with configured options
const handleCreateTask = useCallback(async () => {
if (creating || !selectedContract) return;
setShowContractModal(false);
setCreating(true);
try {
const newTask = await saveTask({
contractId: selectedContract.id,
name: newTaskName || `Task for ${selectedContract.name}`,
plan: "# Plan\n\nDescribe what this task should accomplish...",
repositoryUrl: newTaskRepoUrl || undefined,
targetRepoPath: newTaskTargetPath || undefined,
});
if (newTask) {
navigate(`/mesh/${newTask.id}`);
}
} finally {
setCreating(false);
}
}, [creating, saveTask, navigate, selectedContract, newTaskName, newTaskRepoUrl, newTaskTargetPath]);
// Close modal and reset state
const handleCloseModal = useCallback(() => {
setShowContractModal(false);
setModalStep(1);
setSelectedContract(null);
setNewTaskName("");
setNewTaskRepoUrl(null);
setNewTaskTargetPath("");
}, []);
const handleCreateSubtask = useCallback(async () => {
if (!taskDetail || creating) return;
// Subtasks inherit contract_id from parent
if (!taskDetail.contractId) {
console.error("Parent task has no contract_id");
return;
}
setCreating(true);
try {
const newTask = await saveTask({
contractId: taskDetail.contractId,
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";
}
};
// 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}
onCreateSubtask={handleCreateSubtask}
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"}
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}
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
contracts.length === 0 ? (
<div className="text-center py-8">
<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">
{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>
)
) : (
// Step 2: Configure Task
selectedContract && (
<div className="space-y-4">
{/* Contract badge */}
<div className="flex items-center gap-2 text-xs font-mono text-[#7788aa]">
<span>Contract:</span>
<span className="text-[#9bc3ff]">{selectedContract.name}</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 */}
{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>
)}
{/* Target repo path with DirectoryInput */}
{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}
/>
<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>
);
}