summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes
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
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
Diffstat (limited to 'makima/frontend/src/routes')
-rw-r--r--makima/frontend/src/routes/_index.tsx6
-rw-r--r--makima/frontend/src/routes/contracts.tsx614
-rw-r--r--makima/frontend/src/routes/files.tsx221
-rw-r--r--makima/frontend/src/routes/listen.tsx45
-rw-r--r--makima/frontend/src/routes/mesh.tsx250
-rw-r--r--makima/frontend/src/routes/settings.tsx113
-rw-r--r--makima/frontend/src/routes/workflow.tsx205
7 files changed, 1418 insertions, 36 deletions
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() {
</div>
<span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3">
- Control System
+ <JapaneseHoverText
+ japanese="支配する"
+ english="Control System"
+ />
</span>
<h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide">
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 (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return <ContractsPageContent />;
+}
+
+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<ContractWithRelations | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [newContractName, setNewContractName] = useState("");
+ const [newContractDescription, setNewContractDescription] = useState("");
+ const [initialPhase, setInitialPhase] = useState<ContractPhase>("research");
+ const [repoType, setRepoType] = useState<RepositorySourceType>("remote");
+ const [repoName, setRepoName] = useState("");
+ const [repoUrl, setRepoUrl] = useState("");
+ const [repoPath, setRepoPath] = useState("");
+ const [createError, setCreateError] = useState<string | null>(null);
+ const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+
+ // 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 (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
+ {error && (
+ <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm">
+ {error}
+ </div>
+ )}
+
+ {/* Create contract modal */}
+ {isCreating && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
+ Create Contract
+ </h3>
+
+ {createError && (
+ <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs">
+ {createError}
+ </div>
+ )}
+
+ <div className="space-y-4">
+ {/* Contract name */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Contract Name
+ </label>
+ <input
+ type="text"
+ value={newContractName}
+ onChange={(e) => 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
+ />
+ </div>
+
+ {/* Description */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Description (optional)
+ </label>
+ <textarea
+ value={newContractDescription}
+ onChange={(e) => setNewContractDescription(e.target.value)}
+ placeholder="Describe what this contract is for..."
+ rows={2}
+ 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"
+ />
+ </div>
+
+ {/* Starting Phase */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Starting Phase
+ </label>
+ <select
+ value={initialPhase}
+ onChange={(e) => setInitialPhase(e.target.value as ContractPhase)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ >
+ <option value="research">Research</option>
+ <option value="specify">Specify</option>
+ <option value="plan">Plan</option>
+ <option value="execute">Execute</option>
+ <option value="review">Review</option>
+ </select>
+ <p className="mt-1 font-mono text-xs text-[#8b949e]">
+ Skip earlier phases if you already have requirements defined
+ </p>
+ </div>
+
+ {/* Repository Configuration */}
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+ <label className="block font-mono text-xs text-[#75aafc] uppercase mb-3">
+ Repository Configuration (Required)
+ </label>
+
+ {/* Repository type selector */}
+ <div className="flex gap-2 mb-3">
+ <button
+ type="button"
+ onClick={() => setRepoType("remote")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "remote"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Remote
+ </button>
+ <button
+ type="button"
+ onClick={() => setRepoType("local")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "local"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Local
+ </button>
+ <button
+ type="button"
+ onClick={() => setRepoType("managed")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "managed"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Managed
+ </button>
+ </div>
+
+ {/* Repository name */}
+ <div className="mb-3">
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Repository Name
+ </label>
+ <input
+ type="text"
+ value={repoName}
+ onChange={(e) => 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]"
+ />
+ </div>
+
+ {/* Repository URL (for remote) */}
+ {repoType === "remote" && (
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Repository URL
+ </label>
+ <input
+ type="text"
+ value={repoUrl}
+ onChange={(e) => 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]"
+ />
+ </div>
+ )}
+
+ {/* Repository path (for local) */}
+ {repoType === "local" && (
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Local Path
+ </label>
+ <DirectoryInput
+ value={repoPath}
+ onChange={setRepoPath}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/repository"
+ />
+ </div>
+ )}
+
+ {/* Managed description */}
+ {repoType === "managed" && (
+ <p className="font-mono text-xs text-[#8b949e]">
+ A managed repository will be created automatically by the daemon.
+ </p>
+ )}
+ </div>
+
+ {/* Actions */}
+ <div className="flex gap-2 justify-end pt-2">
+ <button
+ onClick={handleCreateCancel}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreateSubmit}
+ disabled={!newContractName.trim() || !isRepoValid()}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Create
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0">
+ {/* Contract list */}
+ <ContractList
+ contracts={contracts}
+ loading={loading}
+ onSelect={handleSelect}
+ onCreate={handleCreate}
+ selectedId={id}
+ />
+
+ {/* Contract detail or empty state */}
+ {contractDetail ? (
+ <ContractDetail
+ contract={contractDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onUpdate={handleUpdate}
+ onDelete={handleDelete}
+ onPhaseChange={handlePhaseChange}
+ onStatusChange={handleStatusChange}
+ onFileSelect={handleFileSelect}
+ onTaskSelect={handleTaskSelect}
+ onTaskCreate={handleTaskCreate}
+ onRefresh={handleRefresh}
+ onAddRemoteRepo={handleAddRemoteRepo}
+ onAddLocalRepo={handleAddLocalRepo}
+ onCreateManagedRepo={handleCreateManagedRepo}
+ onDeleteRepo={handleDeleteRepo}
+ onSetRepoPrimary={handleSetRepoPrimary}
+ />
+ ) : (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#555] mb-4">
+ Select a contract or create a new one
+ </p>
+ <button
+ onClick={handleCreate}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + New Contract
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
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<FocusedElement | null>(null);
const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
const [createdTask, setCreatedTask] = useState<Task | null>(null);
+ // Contract selection modal state for task creation
+ const [showContractModal, setShowContractModal] = useState(false);
+ const [contracts, setContracts] = useState<ContractSummary[]>([]);
+ 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<number | null>(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() {
</div>
</div>
)}
+
+ {/* Contract Selection Modal for Task Creation */}
+ {showContractModal && (
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+ <div className="bg-[#0d1117] border border-[#30363d] rounded-lg max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
+ <div className="p-4 border-b border-[#30363d] flex justify-between items-center">
+ <h2 className="text-lg font-semibold text-white">Select Contract for Task</h2>
+ <button
+ onClick={() => {
+ setShowContractModal(false);
+ setPendingTaskData(null);
+ }}
+ className="text-[#8b949e] hover:text-white"
+ >
+ <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">
+ {contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#8b949e] mb-4">No contracts found. Create a contract first.</p>
+ <button
+ onClick={() => {
+ setShowContractModal(false);
+ setPendingTaskData(null);
+ navigate("/contracts");
+ }}
+ className="px-4 py-2 bg-[#238636] hover:bg-[#2ea043] text-white rounded-md text-sm"
+ >
+ Create Contract
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {contracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => handleCreateTaskWithContract(contract.id)}
+ className="w-full text-left p-3 rounded-md border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-white font-medium">{contract.name}</span>
+ <span className="text-xs px-2 py-0.5 rounded bg-[#21262d] text-[#8b949e]">
+ {contract.phase}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="text-sm text-[#8b949e] mt-1 line-clamp-2">{contract.description}</p>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Contract Selection Modal for File Creation */}
+ {showFileContractModal && (
+ <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-md 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">
+ <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Select Contract for File</h2>
+ <button
+ onClick={() => {
+ setShowFileContractModal(false);
+ setPendingFileData(null);
+ }}
+ 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">
+ {contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found. Create a contract first.</p>
+ <button
+ onClick={() => {
+ setShowFileContractModal(false);
+ setPendingFileData(null);
+ 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={() => handleCreateFileWithContract(contract.id)}
+ 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>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
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<ContractOption[]>([]);
+ const [selectedContractId, setSelectedContractId] = useState<string | null>(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}
/>
</div>
</main>
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>
);
}
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<string | null>(null);
+ // Daemon state
+ const [daemons, setDaemons] = useState<Daemon[]>([]);
+ const [daemonsLoading, setDaemonsLoading] = useState(true);
+ const [daemonsError, setDaemonsError] = useState<string | null>(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: <code className="text-green-400">makima-daemon</code>
</p>
</section>
+
+ {/* Connected Daemons */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-2">
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]">
+ Daemons
+ </h2>
+ {daemons.length > 0 && (
+ <span className="text-[10px] font-mono text-[#556677]">
+ ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total)
+ </span>
+ )}
+ </div>
+ <button
+ onClick={loadDaemons}
+ disabled={daemonsLoading}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50"
+ title="Refresh"
+ >
+ {daemonsLoading ? "..." : "↻"}
+ </button>
+ </div>
+
+ {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>}
+
+ {daemonsLoading && daemons.length === 0 ? (
+ <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
+ ) : daemons.length === 0 ? (
+ <div className="text-center py-4">
+ <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p>
+ <p className="text-[#556677] font-mono text-[10px]">
+ Start a daemon to enable task execution
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {daemons.map((daemon) => (
+ <div
+ key={daemon.id}
+ className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3"
+ >
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-xs text-[#9bc3ff]">
+ {daemon.hostname || "Unknown Host"}
+ </span>
+ <span
+ className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${
+ daemon.status === "connected"
+ ? "text-green-400 border-green-700/50 bg-green-900/20"
+ : daemon.status === "unhealthy"
+ ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20"
+ : "text-[#8899aa] border-[rgba(117,170,252,0.25)]"
+ }`}
+ >
+ {daemon.status}
+ </span>
+ </div>
+ <div className="font-mono text-[10px] text-[#7788aa] space-y-1">
+ <div className="flex justify-between">
+ <span>Tasks</span>
+ <span className="text-[#9bc3ff]">
+ {daemon.currentTaskCount} / {daemon.maxConcurrentTasks}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span>Connected</span>
+ <span className="text-[#75aafc]">
+ {new Date(daemon.connectedAt).toLocaleString()}
+ </span>
+ </div>
+ {daemon.machineId && (
+ <div className="flex justify-between">
+ <span>Machine</span>
+ <span className="text-[#556677] truncate ml-2" title={daemon.machineId}>
+ {daemon.machineId.substring(0, 16)}...
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
</div>
{/* 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 (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return <WorkflowPageContent />;
+}
+
+function WorkflowPageContent() {
+ const navigate = useNavigate();
+ const { contracts, loading, error, changePhase, saveContract } = useContracts();
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>("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 (
+ <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
+ {error && (
+ <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm shrink-0">
+ {error}
+ </div>
+ )}
+
+ {/* Header with filter and create button */}
+ <div className="flex items-center justify-between shrink-0">
+ <div className="flex items-center gap-4">
+ <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider">
+ Board
+ </h1>
+ {/* Status filter */}
+ <div className="flex items-center gap-1">
+ {(["all", "active", "completed", "archived"] as StatusFilter[]).map(
+ (status) => (
+ <button
+ key={status}
+ onClick={() => setStatusFilter(status)}
+ className={`
+ px-2 py-1 font-mono text-[10px] uppercase transition-colors
+ ${
+ statusFilter === status
+ ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]"
+ : "text-[#555] border border-transparent hover:text-[#75aafc]"
+ }
+ `}
+ >
+ {status}
+ </button>
+ )
+ )}
+ </div>
+ </div>
+ <button
+ onClick={() => setIsCreating(true)}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ + New Contract
+ </button>
+ </div>
+
+ {/* Create contract modal */}
+ {isCreating && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
+ Create Contract
+ </h3>
+ <div className="space-y-4">
+ <input
+ type="text"
+ value={newContractName}
+ onChange={(e) => 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();
+ }}
+ />
+ <div className="flex gap-2 justify-end">
+ <button
+ onClick={handleCancelCreate}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreateContract}
+ disabled={!newContractName.trim()}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Create
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Board */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ {loading ? (
+ <div className="h-full flex items-center justify-center">
+ <p className="font-mono text-sm text-[#555]">Loading...</p>
+ </div>
+ ) : filteredContracts.length === 0 && statusFilter === "all" ? (
+ <div className="h-full flex items-center justify-center">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#555] mb-4">
+ No contracts yet
+ </p>
+ <button
+ onClick={() => setIsCreating(true)}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + Create First Contract
+ </button>
+ </div>
+ </div>
+ ) : (
+ <WorkflowBoard
+ contracts={filteredContracts}
+ onContractClick={handleContractClick}
+ onPhaseChange={handlePhaseChange}
+ />
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}