From 87044a747b47bd83249d61a45842c7f7b2eae56d Mon Sep 17 00:00:00 2001
From: soryu
Date: Sun, 11 Jan 2026 05:52:14 +0000
Subject: Contract system
---
makima/frontend/src/routes/_index.tsx | 6 +-
makima/frontend/src/routes/contracts.tsx | 614 +++++++++++++++++++++++++++++++
makima/frontend/src/routes/files.tsx | 221 +++++++++--
makima/frontend/src/routes/listen.tsx | 45 ++-
makima/frontend/src/routes/mesh.tsx | 250 ++++++++++++-
makima/frontend/src/routes/settings.tsx | 113 ++++++
makima/frontend/src/routes/workflow.tsx | 205 +++++++++++
7 files changed, 1418 insertions(+), 36 deletions(-)
create mode 100644 makima/frontend/src/routes/contracts.tsx
create mode 100644 makima/frontend/src/routes/workflow.tsx
(limited to 'makima/frontend/src/routes')
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx
index 7084c2e..ecdd7f2 100644
--- a/makima/frontend/src/routes/_index.tsx
+++ b/makima/frontend/src/routes/_index.tsx
@@ -1,5 +1,6 @@
import { Masthead } from "../components/Masthead";
import { Logo } from "../components/Logo";
+import { JapaneseHoverText } from "../components/JapaneseHoverText";
export default function HomePage() {
return (
@@ -13,7 +14,10 @@ export default function HomePage() {
- Control System
+
Mesh Orchestration Platform
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
new file mode 100644
index 0000000..8c90804
--- /dev/null
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -0,0 +1,614 @@
+import { useState, useCallback, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { ContractList } from "../components/contracts/ContractList";
+import { ContractDetail } from "../components/contracts/ContractDetail";
+import { DirectoryInput } from "../components/mesh/DirectoryInput";
+import { useContracts } from "../hooks/useContracts";
+import { useAuth } from "../contexts/AuthContext";
+import { createTask, getDaemonDirectories } from "../lib/api";
+import type {
+ ContractWithRelations,
+ ContractPhase,
+ ContractStatus,
+ CreateContractRequest,
+ RepositorySourceType,
+ DaemonDirectory,
+} from "../lib/api";
+
+export default function ContractsPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated (when auth is configured)
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ // Show loading while checking auth
+ if (authLoading) {
+ return (
+
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return ;
+}
+
+function ContractsPageContent() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const {
+ contracts,
+ loading,
+ error,
+ fetchContract,
+ saveContract,
+ editContract,
+ removeContract,
+ changePhase,
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ removeRepo,
+ setRepoPrimary,
+ } = useContracts();
+
+ const [contractDetail, setContractDetail] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [newContractName, setNewContractName] = useState("");
+ const [newContractDescription, setNewContractDescription] = useState("");
+ const [initialPhase, setInitialPhase] = useState("research");
+ const [repoType, setRepoType] = useState("remote");
+ const [repoName, setRepoName] = useState("");
+ const [repoUrl, setRepoUrl] = useState("");
+ const [repoPath, setRepoPath] = useState("");
+ const [createError, setCreateError] = useState(null);
+ const [suggestedDirectories, setSuggestedDirectories] = useState([]);
+
+ // Fetch daemon directories when "local" repo type is selected
+ useEffect(() => {
+ if (repoType === "local" && isCreating) {
+ getDaemonDirectories()
+ .then((res) => setSuggestedDirectories(res.directories))
+ .catch(() => setSuggestedDirectories([]));
+ }
+ }, [repoType, isCreating]);
+
+ // Load contract detail when ID changes
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchContract(id).then((contract) => {
+ setContractDetail(contract);
+ setDetailLoading(false);
+ });
+ } else {
+ setContractDetail(null);
+ }
+ }, [id, fetchContract]);
+
+ const handleSelect = useCallback(
+ (contractId: string) => {
+ navigate(`/contracts/${contractId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ navigate("/contracts");
+ }, [navigate]);
+
+ const handleCreate = useCallback(() => {
+ setIsCreating(true);
+ }, []);
+
+ // Validate repository configuration
+ const isRepoValid = useCallback(() => {
+ if (!repoName.trim()) return false;
+ if (repoType === "remote" && !repoUrl.trim()) return false;
+ if (repoType === "local" && !repoPath.trim()) return false;
+ return true;
+ }, [repoType, repoName, repoUrl, repoPath]);
+
+ const handleCreateSubmit = useCallback(async () => {
+ if (!newContractName.trim()) return;
+ if (!isRepoValid()) {
+ setCreateError("Repository configuration is required");
+ return;
+ }
+
+ setCreateError(null);
+
+ const data: CreateContractRequest = {
+ name: newContractName.trim(),
+ description: newContractDescription.trim() || undefined,
+ initialPhase: initialPhase !== "research" ? initialPhase : undefined,
+ };
+
+ try {
+ const contract = await saveContract(data);
+ if (contract) {
+ // Add the repository after contract creation
+ try {
+ if (repoType === "remote") {
+ await addRemoteRepo(contract.id, {
+ name: repoName.trim(),
+ repositoryUrl: repoUrl.trim(),
+ isPrimary: true,
+ });
+ } else if (repoType === "local") {
+ await addLocalRepo(contract.id, {
+ name: repoName.trim(),
+ localPath: repoPath.trim(),
+ isPrimary: true,
+ });
+ } else if (repoType === "managed") {
+ await createManagedRepo(contract.id, {
+ name: repoName.trim(),
+ isPrimary: true,
+ });
+ }
+ } catch (repoError) {
+ console.error("Failed to add repository:", repoError);
+ // Still navigate to the contract - repo can be added later
+ }
+
+ // Clear form state
+ setIsCreating(false);
+ setNewContractName("");
+ setNewContractDescription("");
+ setInitialPhase("research");
+ setRepoType("remote");
+ setRepoName("");
+ setRepoUrl("");
+ setRepoPath("");
+ navigate(`/contracts/${contract.id}`);
+ }
+ } catch (err) {
+ setCreateError(err instanceof Error ? err.message : "Failed to create contract");
+ }
+ }, [
+ newContractName,
+ newContractDescription,
+ repoType,
+ repoName,
+ repoUrl,
+ repoPath,
+ isRepoValid,
+ saveContract,
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ navigate,
+ ]);
+
+ const handleCreateCancel = useCallback(() => {
+ setIsCreating(false);
+ setNewContractName("");
+ setNewContractDescription("");
+ setInitialPhase("research");
+ setRepoType("remote");
+ setRepoName("");
+ setRepoUrl("");
+ setRepoPath("");
+ setCreateError(null);
+ }, []);
+
+ const handleUpdate = useCallback(
+ async (name: string, description: string) => {
+ if (contractDetail) {
+ const updated = await editContract(contractDetail.id, {
+ name,
+ description: description || undefined,
+ version: contractDetail.version,
+ });
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, editContract, fetchContract]
+ );
+
+ const handleDelete = useCallback(async () => {
+ if (contractDetail && confirm("Are you sure you want to delete this contract?")) {
+ const success = await removeContract(contractDetail.id);
+ if (success) {
+ navigate("/contracts");
+ }
+ }
+ }, [contractDetail, removeContract, navigate]);
+
+ const handlePhaseChange = useCallback(
+ async (phase: ContractPhase) => {
+ if (contractDetail) {
+ const updated = await changePhase(contractDetail.id, phase);
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, changePhase, fetchContract]
+ );
+
+ const handleStatusChange = useCallback(
+ async (status: ContractStatus) => {
+ if (contractDetail) {
+ const updated = await editContract(contractDetail.id, {
+ status,
+ version: contractDetail.version,
+ });
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, editContract, fetchContract]
+ );
+
+ // Repository handlers
+ const handleAddRemoteRepo = useCallback(
+ async (name: string, url: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, addRemoteRepo, fetchContract]
+ );
+
+ const handleAddLocalRepo = useCallback(
+ async (name: string, path: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, addLocalRepo, fetchContract]
+ );
+
+ const handleCreateManagedRepo = useCallback(
+ async (name: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await createManagedRepo(contractDetail.id, { name, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, createManagedRepo, fetchContract]
+ );
+
+ const handleDeleteRepo = useCallback(
+ async (repoId: string) => {
+ if (contractDetail) {
+ await removeRepo(contractDetail.id, repoId);
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, removeRepo, fetchContract]
+ );
+
+ const handleSetRepoPrimary = useCallback(
+ async (repoId: string) => {
+ if (contractDetail) {
+ await setRepoPrimary(contractDetail.id, repoId);
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, setRepoPrimary, fetchContract]
+ );
+
+ // Refresh contract detail (used after file/task operations)
+ const handleRefresh = useCallback(async () => {
+ if (contractDetail) {
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }, [contractDetail, fetchContract]);
+
+ // File/task navigation handlers
+ const handleFileSelect = useCallback(
+ (fileId: string) => {
+ navigate(`/files/${fileId}`);
+ },
+ [navigate]
+ );
+
+ const handleTaskSelect = useCallback(
+ (taskId: string) => {
+ navigate(`/mesh/${taskId}`);
+ },
+ [navigate]
+ );
+
+ // Create task within contract context
+ const handleTaskCreate = useCallback(
+ async (name: string, plan: string, repositoryUrl?: string) => {
+ if (!contractDetail) return;
+ try {
+ // Create the task with contract_id (task is automatically associated)
+ const task = await createTask({
+ contractId: contractDetail.id,
+ name,
+ plan,
+ repositoryUrl,
+ });
+ // Refresh contract detail to show new task
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ // Navigate to the new task
+ navigate(`/mesh/${task.id}`);
+ } catch (e) {
+ console.error("Failed to create task:", e);
+ alert(e instanceof Error ? e.message : "Failed to create task");
+ }
+ },
+ [contractDetail, fetchContract, navigate]
+ );
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Create contract modal */}
+ {isCreating && (
+
+
+
+ Create Contract
+
+
+ {createError && (
+
+ {createError}
+
+ )}
+
+
+ {/* Contract name */}
+
+
+ setNewContractName(e.target.value)}
+ placeholder="Contract 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]"
+ autoFocus
+ />
+
+
+ {/* Description */}
+
+
+
+
+ {/* Starting Phase */}
+
+
+
+
+ Skip earlier phases if you already have requirements defined
+
+
+
+ {/* Repository Configuration */}
+
+
+
+ {/* Repository type selector */}
+
+
+
+
+
+
+ {/* Repository name */}
+
+
+ setRepoName(e.target.value)}
+ placeholder="e.g., my-project"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ />
+
+
+ {/* Repository URL (for remote) */}
+ {repoType === "remote" && (
+
+
+ setRepoUrl(e.target.value)}
+ placeholder="https://github.com/user/repo.git"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ />
+
+ )}
+
+ {/* Repository path (for local) */}
+ {repoType === "local" && (
+
+
+
+
+ )}
+
+ {/* Managed description */}
+ {repoType === "managed" && (
+
+ A managed repository will be created automatically by the daemon.
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Contract list */}
+
+
+ {/* Contract detail or empty state */}
+ {contractDetail ? (
+
+ ) : (
+
+
+
+ Select a contract or create a new one
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 3ba2d52..6cfb3ca 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -12,8 +12,8 @@ import {
useFileSubscription,
type FileUpdateEvent,
} from "../hooks/useFileSubscription";
-import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api";
-import { createTask } from "../lib/api";
+import type { FileDetail as FileDetailType, BodyElement, Task, ContractSummary } from "../lib/api";
+import { createTask, listContracts } from "../lib/api";
import { useAuth } from "../contexts/AuthContext";
export default function FilesPage() {
@@ -59,6 +59,14 @@ function FilesPageContent() {
const [focusedElement, setFocusedElement] = useState(null);
const [suggestedPrompt, setSuggestedPrompt] = useState(null);
const [createdTask, setCreatedTask] = useState(null);
+ // Contract selection modal state for task creation
+ const [showContractModal, setShowContractModal] = useState(false);
+ const [contracts, setContracts] = useState([]);
+ const [contractsLoading, setContractsLoading] = useState(false);
+ const [pendingTaskData, setPendingTaskData] = useState<{ name: string; plan: string } | null>(null);
+ // Contract selection modal state for file creation
+ const [showFileContractModal, setShowFileContractModal] = useState(false);
+ const [pendingFileData, setPendingFileData] = useState<{ name: string; body?: BodyElement[] } | null>(null);
const pendingUpdateRef = useRef(false);
// Track the last version we sent to detect our own updates
const lastSentVersionRef = useRef(null);
@@ -548,10 +556,10 @@ function FilesPageContent() {
[fileDetail]
);
- // Create a mesh task from an element
+ // Create a mesh task from an element - shows contract selection modal
const handleCreateTaskFromElement = useCallback(
async (index: number, selectedText?: string) => {
- if (!fileDetail) return;
+ if (!fileDetail || contractsLoading) return;
const element = fileDetail.body[index];
if (!element) return;
@@ -578,57 +586,98 @@ function FilesPageContent() {
// Create a task name from the content
const name = content.slice(0, 60) + (content.length > 60 ? "..." : "");
+ // Store pending task data and show contract selection modal
+ setPendingTaskData({ name, plan: content });
+ setContractsLoading(true);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setShowContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ },
+ [fileDetail, contractsLoading]
+ );
+
+ // Create task with selected contract
+ const handleCreateTaskWithContract = useCallback(
+ async (contractId: string) => {
+ if (!pendingTaskData || !fileDetail) return;
+ setShowContractModal(false);
try {
const task = await createTask({
- name,
- plan: content,
+ contractId,
+ name: pendingTaskData.name,
+ plan: pendingTaskData.plan,
description: `Created from ${fileDetail.name}`,
});
setCreatedTask(task);
+ setPendingTaskData(null);
} catch (err) {
console.error("Failed to create task:", err);
}
},
- [fileDetail]
+ [pendingTaskData, fileDetail]
);
+ // Open contract selection modal for file creation
const handleCreate = useCallback(async () => {
- if (creating) return;
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setPendingFileData({ name: `Untitled ${new Date().toLocaleDateString()}` });
+ setShowFileContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ }, [creating, contractsLoading]);
+
+ // Create file with selected contract
+ const handleCreateFileWithContract = useCallback(async (contractId: string) => {
+ if (creating || !pendingFileData) return;
+ setShowFileContractModal(false);
setCreating(true);
try {
const newFile = await saveFile({
- name: `Untitled ${new Date().toLocaleDateString()}`,
+ contractId,
+ name: pendingFileData.name,
+ body: pendingFileData.body,
transcript: [],
});
if (newFile) {
+ // If there's body content, update it
+ if (pendingFileData.body && pendingFileData.body.length > 0) {
+ await editFile(newFile.id, { body: pendingFileData.body, version: newFile.version });
+ }
navigate(`/files/${newFile.id}`);
}
} finally {
setCreating(false);
+ setPendingFileData(null);
}
- }, [creating, saveFile, navigate]);
+ }, [creating, pendingFileData, saveFile, editFile, navigate]);
const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => {
- if (creating) return;
- setCreating(true);
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
try {
- const newFile = await saveFile({
- name,
- transcript: [],
- });
- if (newFile) {
- // Update with the parsed body
- const updated = await editFile(newFile.id, { body, version: newFile.version });
- if (updated) {
- navigate(`/files/${updated.id}`);
- } else {
- navigate(`/files/${newFile.id}`);
- }
- }
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setPendingFileData({ name, body });
+ setShowFileContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
} finally {
- setCreating(false);
+ setContractsLoading(false);
}
- }, [creating, saveFile, editFile, navigate]);
+ }, [creating, contractsLoading]);
// Conflict resolution handlers
const handleConflictReload = useCallback(async () => {
@@ -808,6 +857,124 @@ function FilesPageContent() {
)}
+
+ {/* Contract Selection Modal for Task Creation */}
+ {showContractModal && (
+
+
+
+
Select Contract for Task
+
+
+
+ {contracts.length === 0 ? (
+
+
No contracts found. Create a contract first.
+
+
+ ) : (
+
+ {contracts.map((contract) => (
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ {/* Contract Selection Modal for File Creation */}
+ {showFileContractModal && (
+
+
+
+
Select Contract for File
+
+
+
+ {contracts.length === 0 ? (
+
+
No contracts found. Create a contract first.
+
+
+ ) : (
+
+ {contracts.map((contract) => (
+
+ ))}
+
+ )}
+
+
+
+ )}
);
}
diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx
index aaba90c..36c468b 100644
--- a/makima/frontend/src/routes/listen.tsx
+++ b/makima/frontend/src/routes/listen.tsx
@@ -2,9 +2,11 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { Masthead } from "../components/Masthead";
import { SpeakerPanel } from "../components/listen/SpeakerPanel";
import { TranscriptPanel } from "../components/listen/TranscriptPanel";
-import { ControlPanel } from "../components/listen/ControlPanel";
+import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel";
import { useMicrophone } from "../hooks/useMicrophone";
import { useWebSocket } from "../hooks/useWebSocket";
+import { listContracts } from "../lib/api";
+import { useAuth } from "../contexts/AuthContext";
export default function ListenPage() {
const [isListening, setIsListening] = useState(false);
@@ -12,6 +14,37 @@ export default function ListenPage() {
const [permissionRequested, setPermissionRequested] = useState(false);
const isListeningRef = useRef(false);
+ // Contract selection state
+ const [contracts, setContracts] = useState([]);
+ const [selectedContractId, setSelectedContractId] = useState(null);
+ const [contractsLoading, setContractsLoading] = useState(true);
+ const { session, isAuthenticated } = useAuth();
+
+ // Fetch contracts on mount
+ useEffect(() => {
+ if (!isAuthenticated) {
+ setContractsLoading(false);
+ return;
+ }
+
+ async function fetchContracts() {
+ try {
+ const response = await listContracts();
+ setContracts(
+ response.contracts.map((c) => ({
+ id: c.id,
+ name: c.name,
+ }))
+ );
+ } catch (err) {
+ console.error("Failed to fetch contracts:", err);
+ } finally {
+ setContractsLoading(false);
+ }
+ }
+ fetchContracts();
+ }, [isAuthenticated]);
+
// Keep ref in sync with state for use in callbacks
useEffect(() => {
isListeningRef.current = isListening;
@@ -108,9 +141,11 @@ export default function ListenPage() {
}
// Both microphone and WebSocket are ready - start the session
- ws.startSession(mic.sampleRate, mic.channels);
+ // Pass contract_id and auth token if available
+ const authToken = session?.access_token || null;
+ ws.startSession(mic.sampleRate, mic.channels, selectedContractId, authToken);
setIsListening(true);
- }, [isListening, mic, ws]);
+ }, [isListening, mic, ws, selectedContractId, session]);
const handleNew = useCallback(() => {
// Stop current session - backend auto-saves transcript on disconnect
@@ -152,6 +187,10 @@ export default function ListenPage() {
onToggle={handleToggle}
onNew={handleNew}
error={error}
+ contracts={contracts}
+ selectedContractId={selectedContractId}
+ onContractChange={setSelectedContractId}
+ contractsLoading={contractsLoading}
/>
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([]);
const [isStreaming, setIsStreaming] = useState(false);
+ // Contract selection modal state
+ const [showContractModal, setShowContractModal] = useState(false);
+ const [contracts, setContracts] = useState([]);
+ const [contractsLoading, setContractsLoading] = useState(false);
+ // Task creation modal (step 2)
+ const [modalStep, setModalStep] = useState<1 | 2>(1);
+ const [selectedContract, setSelectedContract] = useState(null);
+ const [daemonDirectories, setDaemonDirectories] = useState([]);
+ const [newTaskName, setNewTaskName] = useState("");
+ const [newTaskRepoUrl, setNewTaskRepoUrl] = useState(null);
+ const [newTaskTargetPath, setNewTaskTargetPath] = useState("");
// Track which subtask's output we're viewing (null = parent task)
const [viewingSubtaskId, setViewingSubtaskId] = useState(null);
const [viewingSubtaskName, setViewingSubtaskName] = useState(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}`)}
/>
)}
@@ -662,6 +749,159 @@ export default function MeshPage() {
+
+ {/* Task Creation Modal (Two Steps) */}
+ {showContractModal && (
+
+
+
+
+ {modalStep === 2 && (
+
+ )}
+
+ {modalStep === 1 ? "Select Contract" : "Configure Task"}
+
+
+
+
+
+ {modalStep === 1 ? (
+ // Step 1: Select Contract
+ contracts.length === 0 ? (
+
+
No contracts found.
+
+
+ ) : (
+
+ {contracts.map((contract) => (
+
+ ))}
+
+ )
+ ) : (
+ // Step 2: Configure Task
+ selectedContract && (
+
+ {/* Contract badge */}
+
+ Contract:
+ {selectedContract.name}
+
+
+ {/* Task name */}
+
+
+ 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"
+ />
+
+
+ {/* Repository selection */}
+ {selectedContract.repositories.length > 0 && (
+
+
+
+
+ The repository this task will work on.
+
+
+ )}
+
+ {/* Target repo path with DirectoryInput */}
+ {newTaskRepoUrl && (
+
+
+
+
+ Path where the task will push/merge changes. Leave empty to configure later.
+
+
+ )}
+
+ {/* Create button */}
+
+
+
+
+ )
+ )}
+
+
+
+ )}
);
}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
index 6d56e67..7ca40ba 100644
--- a/makima/frontend/src/routes/settings.tsx
+++ b/makima/frontend/src/routes/settings.tsx
@@ -10,8 +10,10 @@ import {
changePassword,
changeEmail,
deleteAccount,
+ listDaemons,
type ApiKeyInfo,
type CreateApiKeyResponse,
+ type Daemon,
} from "../lib/api";
// =============================================================================
@@ -297,8 +299,22 @@ export default function SettingsPage() {
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState(null);
+ // Daemon state
+ const [daemons, setDaemons] = useState([]);
+ const [daemonsLoading, setDaemonsLoading] = useState(true);
+ const [daemonsError, setDaemonsError] = useState(null);
+
useEffect(() => {
loadApiKey();
+ loadDaemons();
+ }, []);
+
+ // Auto-refresh daemons every 30 seconds
+ useEffect(() => {
+ const interval = setInterval(() => {
+ loadDaemons();
+ }, 30000);
+ return () => clearInterval(interval);
}, []);
const loadApiKey = async () => {
@@ -314,6 +330,18 @@ export default function SettingsPage() {
}
};
+ const loadDaemons = async () => {
+ try {
+ setDaemonsError(null);
+ const response = await listDaemons();
+ setDaemons(response.daemons);
+ } catch (err) {
+ setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons");
+ } finally {
+ setDaemonsLoading(false);
+ }
+ };
+
const handleCreate = async () => {
try {
setActionLoading(true);
@@ -579,6 +607,91 @@ export default function SettingsPage() {
Then run: makima-daemon
+
+ {/* Connected Daemons */}
+
+
+
+
+ Daemons
+
+ {daemons.length > 0 && (
+
+ ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total)
+
+ )}
+
+
+
+
+ {daemonsError && {daemonsError}}
+
+ {daemonsLoading && daemons.length === 0 ? (
+ Loading...
+ ) : daemons.length === 0 ? (
+
+
No daemons connected
+
+ Start a daemon to enable task execution
+
+
+ ) : (
+
+ {daemons.map((daemon) => (
+
+
+
+ {daemon.hostname || "Unknown Host"}
+
+
+ {daemon.status}
+
+
+
+
+ Tasks
+
+ {daemon.currentTaskCount} / {daemon.maxConcurrentTasks}
+
+
+
+ Connected
+
+ {new Date(daemon.connectedAt).toLocaleString()}
+
+
+ {daemon.machineId && (
+
+ Machine
+
+ {daemon.machineId.substring(0, 16)}...
+
+
+ )}
+
+
+ ))}
+
+ )}
+
{/* Right Column */}
diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx
new file mode 100644
index 0000000..cb72e9e
--- /dev/null
+++ b/makima/frontend/src/routes/workflow.tsx
@@ -0,0 +1,205 @@
+import { useState, useCallback, useEffect, useMemo } from "react";
+import { useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { WorkflowBoard } from "../components/workflow/WorkflowBoard";
+import { useContracts } from "../hooks/useContracts";
+import { useAuth } from "../contexts/AuthContext";
+import type { ContractPhase, ContractStatus } from "../lib/api";
+
+type StatusFilter = "all" | ContractStatus;
+
+export default function WorkflowPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated (when auth is configured)
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ // Show loading while checking auth
+ if (authLoading) {
+ return (
+
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return ;
+}
+
+function WorkflowPageContent() {
+ const navigate = useNavigate();
+ const { contracts, loading, error, changePhase, saveContract } = useContracts();
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [isCreating, setIsCreating] = useState(false);
+ const [newContractName, setNewContractName] = useState("");
+
+ // Filter contracts by status
+ const filteredContracts = useMemo(() => {
+ if (statusFilter === "all") {
+ return contracts;
+ }
+ return contracts.filter((c) => c.status === statusFilter);
+ }, [contracts, statusFilter]);
+
+ const handleContractClick = useCallback(
+ (contractId: string) => {
+ navigate(`/contracts/${contractId}`);
+ },
+ [navigate]
+ );
+
+ const handlePhaseChange = useCallback(
+ async (contractId: string, newPhase: ContractPhase) => {
+ await changePhase(contractId, newPhase);
+ },
+ [changePhase]
+ );
+
+ const handleCreateContract = useCallback(async () => {
+ if (!newContractName.trim()) return;
+ const contract = await saveContract({
+ name: newContractName.trim(),
+ });
+ if (contract) {
+ setNewContractName("");
+ setIsCreating(false);
+ navigate(`/contracts/${contract.id}`);
+ }
+ }, [newContractName, saveContract, navigate]);
+
+ const handleCancelCreate = useCallback(() => {
+ setNewContractName("");
+ setIsCreating(false);
+ }, []);
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Header with filter and create button */}
+
+
+
+ Board
+
+ {/* Status filter */}
+
+ {(["all", "active", "completed", "archived"] as StatusFilter[]).map(
+ (status) => (
+
+ )
+ )}
+
+
+
+
+
+ {/* Create contract modal */}
+ {isCreating && (
+
+
+
+ Create Contract
+
+
+
setNewContractName(e.target.value)}
+ placeholder="Contract 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]"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCreateContract();
+ if (e.key === "Escape") handleCancelCreate();
+ }}
+ />
+
+
+
+
+
+
+
+ )}
+
+ {/* Board */}
+
+ {loading ? (
+
+ ) : filteredContracts.length === 0 && statusFilter === "all" ? (
+
+
+
+ No contracts yet
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
--
cgit v1.2.3