summaryrefslogtreecommitdiff
path: root/makima/frontend
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-21 17:31:46 +0000
committerGitHub <noreply@github.com>2026-01-21 17:31:46 +0000
commit94e5604e770d6589f786ea71e51738e21492f301 (patch)
tree6c9b0f32a8d77464bc1a5131ba0828d252851abc /makima/frontend
parentda246c4c4e23c9ad976705f9a3fa80e0d75b4425 (diff)
downloadsoryu-94e5604e770d6589f786ea71e51738e21492f301.tar.gz
soryu-94e5604e770d6589f786ea71e51738e21492f301.zip
Add task branching feature (#15)
Diffstat (limited to 'makima/frontend')
-rw-r--r--makima/frontend/src/components/mesh/BranchTaskModal.tsx132
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx26
-rw-r--r--makima/frontend/src/lib/api.ts47
-rw-r--r--makima/frontend/src/routes/mesh.tsx22
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">&times;</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>