summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/mesh.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/routes/mesh.tsx
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
Diffstat (limited to 'makima/frontend/src/routes/mesh.tsx')
-rw-r--r--makima/frontend/src/routes/mesh.tsx250
1 files changed, 245 insertions, 5 deletions
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
index 7ecf96d..d067865 100644
--- a/makima/frontend/src/routes/mesh.tsx
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -7,8 +7,9 @@ import { TaskOutput } from "../components/mesh/TaskOutput";
import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput";
import { useTasks } from "../hooks/useTasks";
import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
-import type { TaskWithSubtasks, MeshChatContext } from "../lib/api";
-import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api";
+import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory } from "../lib/api";
+import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories } from "../lib/api";
+import { DirectoryInput } from "../components/mesh/DirectoryInput";
import { useAuth } from "../contexts/AuthContext";
// View modes for the task detail page
@@ -91,6 +92,17 @@ export default function MeshPage() {
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);
@@ -139,6 +151,14 @@ export default function MeshPage() {
// 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];
@@ -383,13 +403,63 @@ export default function MeshPage() {
[editTask, taskDetail]
);
+ // Open contract selection modal
const handleCreate = useCallback(async () => {
- if (creating) return;
+ 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({
- name: `Task ${new Date().toLocaleDateString()}`,
+ 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}`);
@@ -397,13 +467,29 @@ export default function MeshPage() {
} finally {
setCreating(false);
}
- }, [creating, saveTask, navigate]);
+ }, [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,
@@ -597,6 +683,7 @@ export default function MeshPage() {
onCreateSubtask={handleCreateSubtask}
onToggleSubtaskOutput={handleToggleSubtaskOutput}
viewingSubtaskId={viewingSubtaskId}
+ onViewContract={(contractId) => navigate(`/contracts/${contractId}`)}
/>
</div>
)}
@@ -662,6 +749,159 @@ export default function MeshPage() {
</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>
);
}