diff options
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/mesh/BranchTaskModal.tsx | 132 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskDetail.tsx | 26 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 47 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 22 |
4 files changed, 224 insertions, 3 deletions
diff --git a/makima/frontend/src/components/mesh/BranchTaskModal.tsx b/makima/frontend/src/components/mesh/BranchTaskModal.tsx new file mode 100644 index 0000000..ade4c7d --- /dev/null +++ b/makima/frontend/src/components/mesh/BranchTaskModal.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import type { TaskWithSubtasks } from "../../lib/api"; + +interface BranchTaskModalProps { + task: TaskWithSubtasks; + onBranch: (taskId: string, message: string, name?: string) => Promise<void>; + onClose: () => void; +} + +export function BranchTaskModal({ + task, + onBranch, + onClose, +}: BranchTaskModalProps) { + const [name, setName] = useState(`Branch of ${task.name}`); + const [message, setMessage] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState<string | null>(null); + + const handleSubmit = async () => { + if (!message.trim()) { + setError("Message is required"); + return; + } + + setSubmitting(true); + setError(null); + + try { + await onBranch(task.id, message.trim(), name.trim() || undefined); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create branch"); + } finally { + setSubmitting(false); + } + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"> + <div className="w-full max-w-lg mx-4 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-2xl"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-2"> + <span className="text-purple-400 text-lg">*</span> + <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wide"> + Branch Task + </h2> + </div> + <button + onClick={onClose} + className="text-[#555] hover:text-[#9bc3ff] transition-colors" + aria-label="Close" + > + <span className="text-xl">×</span> + </button> + </div> + + {/* Content */} + <div className="p-4 space-y-4"> + {/* Source task info */} + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.15)] p-3"> + <p className="font-mono text-xs text-[#9bc3ff] mb-1 uppercase"> + Branching From + </p> + <p className="font-mono text-sm text-[#dbe7ff]">{task.name}</p> + <p className="font-mono text-[10px] text-[#555] mt-1"> + Status: {task.status} + </p> + </div> + + {/* Name input */} + <div className="space-y-2"> + <label className="block font-mono text-xs text-[#9bc3ff] uppercase"> + Branch Name + </label> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Enter branch task name..." + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + </div> + + {/* Message input */} + <div className="space-y-2"> + <label className="block font-mono text-xs text-[#9bc3ff] uppercase"> + Message <span className="text-red-400">*</span> + </label> + <textarea + value={message} + onChange={(e) => setMessage(e.target.value)} + placeholder="Enter a message for the new task to continue with..." + rows={4} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + autoFocus + /> + <p className="font-mono text-[10px] text-[#555]"> + This message will be sent to the branched task when it starts. + </p> + </div> + + {/* Error message */} + {error && ( + <div className="bg-red-900/20 border border-red-500/30 p-3 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Actions */} + <div className="flex gap-3 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <button + onClick={onClose} + disabled={submitting} + className="flex-1 px-4 py-2.5 font-mono text-sm text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + Cancel + </button> + <button + onClick={handleSubmit} + disabled={submitting || !message.trim()} + className="flex-1 px-4 py-2.5 font-mono text-sm text-[#dbe7ff] bg-purple-700/30 border border-purple-400/50 hover:bg-purple-700/40 hover:border-purple-400/70 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {submitting ? "Creating..." : "Create Branch"} + </button> + </div> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index efe26a8..a74f394 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -6,6 +6,7 @@ import { OverlayDiffViewer } from "./OverlayDiffViewer"; import { PRPreview } from "./PRPreview"; import { InlineSubtaskEditor } from "./InlineSubtaskEditor"; import { DirectoryInput } from "./DirectoryInput"; +import { BranchTaskModal } from "./BranchTaskModal"; interface TaskDetailProps { task: TaskWithSubtasks; @@ -25,6 +26,8 @@ interface TaskDetailProps { viewingSubtaskId?: string | null; /** Navigate to view the contract */ onViewContract?: (contractId: string) => void; + /** Branch the task to create a new task with same state */ + onBranch?: (taskId: string, message: string, name?: string) => Promise<void>; // Optional advanced features overlayDiff?: string; changedFiles?: string[]; @@ -110,6 +113,7 @@ export function TaskDetail({ onToggleSubtaskOutput, viewingSubtaskId, onViewContract, + onBranch, overlayDiff, changedFiles, onRequestDiff, @@ -142,6 +146,8 @@ export function TaskDetail({ const [isCloning, setIsCloning] = useState(false); const [cloneError, setCloneError] = useState<string | null>(null); const [cloneTargetDir, setCloneTargetDir] = useState(""); + // Track branch modal state + const [showBranchModal, setShowBranchModal] = useState(false); // Check if task is running const isTaskRunning = task.status === "running" || task.status === "initializing" || task.status === "starting"; @@ -151,6 +157,8 @@ export function TaskDetail({ const isSupervisor = task.isSupervisor === true; // Show continue for supervisors (always) or terminal states for other tasks const canContinue = isSupervisor || isTaskTerminal; + // Show branch button when task has run at least once (not pending) + const canBranch = onBranch && task.status !== "pending"; // Determine which tasks to show: for supervisors, show contractTasks; for regular tasks, show subtasks const displayTasks = useMemo(() => { @@ -380,6 +388,15 @@ export function TaskDetail({ Continue </button> )} + {canBranch && ( + <button + onClick={() => setShowBranchModal(true)} + className="px-3 py-1 font-mono text-xs text-purple-400 border border-purple-400/30 hover:border-purple-400/50 hover:bg-purple-400/10 transition-colors uppercase flex items-center gap-1" + > + <span className="w-1.5 h-1.5 bg-purple-400 rounded-full" /> + Branch + </button> + )} <button onClick={() => setIsEditing(true)} className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" @@ -908,6 +925,15 @@ export function TaskDetail({ </div> </div> )} + + {/* Branch Task Modal */} + {showBranchModal && onBranch && ( + <BranchTaskModal + task={task} + onBranch={onBranch} + onClose={() => setShowBranchModal(false)} + /> + )} </div> ); } diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 14ec9f2..86ff06c 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -607,8 +607,8 @@ export interface TaskListResponse { } export interface CreateTaskRequest { - /** Contract this task belongs to (required) */ - contractId: string; + /** Contract this task belongs to (optional - can be standalone) */ + contractId?: string; name: string; description?: string; plan: string; @@ -974,6 +974,49 @@ export async function listSubtasks(taskId: string): Promise<TaskListResponse> { return res.json(); } +// ============================================================================= +// Task Branching +// ============================================================================= + +/** Request to branch a task */ +export interface BranchTaskRequest { + /** Message to send to the new branched task */ + message: string; + /** Optional name for the new task (defaults to "Branch of {original task name}") */ + name?: string; + /** Whether to include conversation history from the source task */ + includeConversation?: boolean; +} + +/** Response from branching a task */ +export interface BranchTaskResponse { + /** The newly created task */ + task: Task; + /** Number of conversation messages copied to the new task */ + messageCount: number; + /** ID of the daemon assigned to the new task (null if not yet assigned) */ + daemonId: string | null; +} + +/** + * Branch a task to create a new task with the same state. + * Copies the worktree and optionally the conversation history. + */ +export async function branchTask( + taskId: string, + request: BranchTaskRequest +): Promise<BranchTaskResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/branch`, { + method: "POST", + body: JSON.stringify(request), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to branch task: ${errorText || res.statusText}`); + } + return res.json(); +} + export async function listTaskEvents( taskId: string ): Promise<TaskEventListResponse> { diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 142cc54..453bdff 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -9,7 +9,7 @@ import { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQue 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 } 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"; @@ -461,6 +461,25 @@ export default function MeshPage() { [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; @@ -742,6 +761,7 @@ export default function MeshPage() { onToggleSubtaskOutput={handleToggleSubtaskOutput} viewingSubtaskId={viewingSubtaskId} onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} + onBranch={handleBranch} contractTasks={taskDetail.isSupervisor ? contractTasks : undefined} /> </div> |
