From e11759447b1ac00becfb1e979e488f7f9c9cf478 Mon Sep 17 00:00:00 2001 From: soryu Date: Fri, 1 May 2026 23:56:51 +0100 Subject: chore(cleanup): Phase 5 contracts removal + tmp directive + 30-day expiry + scroll fix (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeping cleanup across the surface and the wire. Net: -14k LOC of legacy contracts code, plus the tmp/scroll/UX fixes the user asked for. ## Sidebar/editor independent scroll Replace `height: calc(100vh - 80px)` (which assumed an 80px masthead and quietly clipped or pushed the whole page below the fold when the masthead was taller) with `h-screen + overflow-hidden` on the page root and proper `flex-1 min-h-0` sizing on `
`. Sidebar and editor pane now manage their own scroll independently; the page itself never scrolls. Same fix in /tmp/:taskId. ## tmp directive — real backing for orphans/ephemerals New migration `20260501100000_tmp_directive_and_clear_orphans.sql`: * Adds `directives.is_tmp` BOOLEAN NOT NULL DEFAULT false. * Partial unique index `(owner_id) WHERE is_tmp` — at most ONE tmp directive per owner. * Hard-deletes every existing orphan task (`directive_id IS NULL`). Per the user spec: "ALSO there are TOO MANY old tasks in tmp, we need to remove all of them as well." New repository helpers: * `get_or_create_tmp_directive(pool, owner_id) -> Directive` INSERT ON CONFLICT DO NOTHING + fallback SELECT, race-safe. * `list_all_tmp_directives` — drives the expiry sweep. * `delete_expired_tmp_tasks(tmp_directive_id) -> u64`. * `list_tmp_tasks_for_owner` (replaces `list_orphan_tasks_for_owner`). `mesh::create_task`: every top-level task must have a directive. If a caller doesn't supply `directive_id` and isn't a subtask, attach to the caller's tmp directive (auto-creating it on first use). `list_directives_for_owner` filters out `is_tmp=true` so the scratchpad directive doesn't pollute the contract list — surfaced via the sidebar's `tmp/` folder instead. ## 30-day expiry on tmp tasks New `phase_tmp_expiry` in the directive reconciler. Throttled to once per hour: enumerates every tmp directive, calls `delete_expired_tmp_tasks`, logs the count. The actual delete is `WHERE created_at < NOW() - INTERVAL '30 days'` and is fast on the existing index. Subtasks die via FK cascade. ## Phase 5 — contracts removed ### Frontend Deleted entire `/contracts` surface: * routes: `contracts.tsx`, `contract-file.tsx` * components/contracts: ContractList, ContractDetail, ContractCliInput, ContractContextMenu, CommandModePanel, PhaseBadge, PhaseHint, PhaseDeliverablesPanel, PhaseProgressBar, QuickActionButtons, RepositoryPanel, TaskDerivationPreview * (Kept `PhaseConfirmationModal` — used outside the contracts surface by `TaskOutput` and `PhaseConfirmationNotification`.) * Routes deregistered from `main.tsx`; nav entry removed from `NavStrip`. ### Backend handlers Deleted: `contracts.rs` (2.4k LOC), `contract_chat.rs` (3.2k LOC), `contract_daemon.rs` (~940 LOC), `contract_discuss.rs` (~590 LOC), `transcript_analysis.rs` (~690 LOC). All `/api/v1/contracts/*` routes deregistered. OpenAPI entries dropped. Module declarations removed from `server/handlers/mod.rs`. ### CLI Removed `makima contract` and `makima supervisor` subcommands. Deleted `daemon/cli/contract.rs` and `daemon/cli/supervisor.rs`. Bin dispatch trimmed (~377 LOC). ### Orchestrator Removed the contract-spawn path from `phase_execution` (`spawn_step_contract` and its caller). `directive_steps.contract_type` now logs a warning and falls through to standalone-task spawn. Column itself stays — old data still reads, just no longer triggers a contract+supervisor spawn. ### TUI `Action::PerformCreateContract` is now a no-op that surfaces a status message: "Contracts have been removed. Use directives instead." The TUI form is dead code pending a wider refresh. ## Out of scope (deliberately left) * Contracts DB tables (`contracts`, `contract_repositories`, `contract_chat_history`, `contract_events`, `contract_templates`) are retained for historical data + because some peripheral code still joins to them in TaskSummary queries. * `mesh_supervisor` handlers are retained — they aren't only used by contracts (some mesh-level supervisor behaviour persists), and the cross-cutting cleanup is bigger than this PR. * `directive_steps.contract_type` column itself isn't dropped; just no longer functional. Co-authored-by: Claude Opus 4.7 (1M context) --- makima/frontend/src/routes/contract-file.tsx | 659 --------------- makima/frontend/src/routes/contracts.tsx | 885 --------------------- makima/frontend/src/routes/document-directives.tsx | 16 +- makima/frontend/src/routes/tmp.tsx | 9 +- 4 files changed, 13 insertions(+), 1556 deletions(-) delete mode 100644 makima/frontend/src/routes/contract-file.tsx delete mode 100644 makima/frontend/src/routes/contracts.tsx (limited to 'makima/frontend/src/routes') diff --git a/makima/frontend/src/routes/contract-file.tsx b/makima/frontend/src/routes/contract-file.tsx deleted file mode 100644 index 9ed25ed..0000000 --- a/makima/frontend/src/routes/contract-file.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { useParams, useNavigate } from "react-router"; -import { useAuth } from "../contexts/AuthContext"; -import { Masthead } from "../components/Masthead"; -import { FileDetail, type FocusedElement } from "../components/files/FileDetail"; -import { CliInput } from "../components/files/CliInput"; -import { ConflictNotification } from "../components/files/ConflictNotification"; -import { UpdateNotification } from "../components/files/UpdateNotification"; -import { useFiles } from "../hooks/useFiles"; -import { useVersionHistory } from "../hooks/useVersionHistory"; -import { - useFileSubscription, - type FileUpdateEvent, -} from "../hooks/useFileSubscription"; -import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; - -/** - * ContractFilePage - Wrapper for viewing files within a contract context - * - * This component handles the /contracts/:contractId/files/:fileId route, - * providing navigation back to the contract and rendering the file detail view. - */ -export default function ContractFilePage() { - const { id: contractId, fileId } = useParams<{ id: string; fileId: string }>(); - 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 ( -
- -
-

Loading...

-
-
- ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - // Render the file page with contract context - return ; -} - -// A version of the files page aware of contract context -function ContractAwareFilesPage({ - contractId, - fileId, -}: { - contractId?: string; - fileId?: string; -}) { - const navigate = useNavigate(); - const { error, conflict, clearConflict, fetchFile, editFile, removeFile } = useFiles(); - const [fileDetail, setFileDetail] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [remoteUpdate, setRemoteUpdate] = useState(null); - const [remoteFileData, setRemoteFileData] = useState(null); - const [focusedElement, setFocusedElement] = useState(null); - const [suggestedPrompt, setSuggestedPrompt] = useState(null); - const pendingUpdateRef = useRef(false); - const lastSentVersionRef = useRef(null); - const lastSavedVersionRef = useRef(null); - const hasLocalChangesRef = useRef(false); - const isActivelyEditingRef = useRef(false); - const currentVersionRef = useRef(null); - - // Handle back navigation - go to contract detail instead of /files - const handleBack = useCallback(() => { - if (contractId) { - navigate(`/contracts/${contractId}`); - } else { - navigate("/contracts"); - } - }, [contractId, navigate]); - - const updateHasLocalChanges = useCallback((value: boolean) => { - hasLocalChangesRef.current = value; - }, []); - - const updateIsActivelyEditing = useCallback((value: boolean) => { - isActivelyEditingRef.current = value; - }, []); - - // Version history - const { - versions, - loading: versionsLoading, - selectedVersion, - loadingVersion, - restoring, - fetchVersion, - restoreToVersion, - clearSelectedVersion, - fetchVersions, - } = useVersionHistory({ - fileId: fileId || null, - currentVersion: fileDetail?.version || 0, - }); - - const handleRestoreVersion = useCallback( - async (targetVersion: number) => { - const result = await restoreToVersion(targetVersion); - if (result) { - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - fetchVersions(); - } - }, - [restoreToVersion, fetchVersions, updateHasLocalChanges] - ); - - // Load file detail when fileId is provided - useEffect(() => { - if (fileId) { - setDetailLoading(true); - updateHasLocalChanges(false); - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - lastSavedVersionRef.current = null; - currentVersionRef.current = null; - setRemoteUpdate(null); - setRemoteFileData(null); - setFocusedElement(null); - fetchFile(fileId).then((detail) => { - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - setDetailLoading(false); - }); - } else { - setFileDetail(null); - currentVersionRef.current = null; - updateHasLocalChanges(false); - } - }, [fileId, fetchFile, updateHasLocalChanges]); - - // Handle file update events from WebSocket - const handleFileUpdate = useCallback( - async (event: FileUpdateEvent) => { - if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) { - lastSavedVersionRef.current = null; - return; - } - - if (pendingUpdateRef.current) { - if (lastSentVersionRef.current !== null) { - const expectedNewVersion = lastSentVersionRef.current + 1; - if (event.version === expectedNewVersion) { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - return; - } - } - return; - } - - if (currentVersionRef.current !== null && event.version === currentVersionRef.current) { - return; - } - - if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) { - const detail = await fetchFile(event.fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - } else { - const remoteData = await fetchFile(event.fileId); - setRemoteFileData(remoteData); - setRemoteUpdate(event); - } - }, - [fetchFile] - ); - - useFileSubscription({ - fileId: fileId || null, - onUpdate: handleFileUpdate, - }); - - const handleDelete = useCallback( - async (id: string) => { - if (confirm("Are you sure you want to delete this file?")) { - const success = await removeFile(id); - if (success && fileId === id) { - handleBack(); - } - } - }, - [removeFile, fileId, handleBack] - ); - - const handleSave = useCallback( - async (id: string, name: string, description: string) => { - if (!fileDetail) return; - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(id, { name, description, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - }, - [editFile, fileDetail, updateHasLocalChanges] - ); - - const handleBodyUpdate = useCallback( - (body: BodyElement[], summary: string | null) => { - if (fileDetail) { - setFileDetail({ - ...fileDetail, - body, - summary, - }); - } - }, - [fileDetail] - ); - - const handleBodyElementUpdate = useCallback( - async (index: number, element: BodyElement) => { - if (fileDetail && fileId) { - const newBody = [...fileDetail.body]; - newBody[index] = element; - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges] - ); - - const handleBodyReorder = useCallback( - async (fromIndex: number, toIndex: number) => { - if (fileDetail && fileId) { - const newBody = [...fileDetail.body]; - const [movedElement] = newBody.splice(fromIndex, 1); - newBody.splice(toIndex, 0, movedElement); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges] - ); - - const handleBodyElementDelete = useCallback( - async (index: number) => { - if (fileDetail && fileId) { - const newBody = fileDetail.body.filter((_, i) => i !== index); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - if (focusedElement?.index === index) { - setFocusedElement(null); - } else if (focusedElement && focusedElement.index > index) { - setFocusedElement({ - ...focusedElement, - index: focusedElement.index - 1, - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleBodyElementDuplicate = useCallback( - async (index: number) => { - if (fileDetail && fileId) { - const elementToDuplicate = fileDetail.body[index]; - if (!elementToDuplicate) return; - - const newBody = [...fileDetail.body]; - newBody.splice(index + 1, 0, { ...elementToDuplicate }); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - if (focusedElement && focusedElement.index > index) { - setFocusedElement({ - ...focusedElement, - index: focusedElement.index + 1, - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleFocusElement = useCallback((element: FocusedElement | null) => { - setFocusedElement(element); - }, []); - - const handleClearFocus = useCallback(() => { - setFocusedElement(null); - }, []); - - const handleConvertElement = useCallback( - async (index: number, toType: string) => { - if (!fileDetail || !fileId) return; - - const element = fileDetail.body[index]; - if (!element) return; - - let textContent = ""; - switch (element.type) { - case "heading": - case "paragraph": - textContent = element.text; - break; - case "code": - textContent = element.content; - break; - case "list": - textContent = element.items.join("\n"); - break; - default: - return; - } - - let newElement: BodyElement; - if (toType === "paragraph") { - newElement = { type: "paragraph", text: textContent }; - } else if (toType === "list_unordered") { - const items = textContent.split("\n").filter(line => line.trim()); - newElement = { type: "list", ordered: false, items }; - } else if (toType === "list_ordered") { - const items = textContent.split("\n").filter(line => line.trim()); - newElement = { type: "list", ordered: true, items }; - } else if (toType === "code") { - newElement = { type: "code", content: textContent }; - } else if (toType.startsWith("heading_")) { - const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6; - newElement = { type: "heading", level, text: textContent }; - } else { - return; - } - - const newBody = [...fileDetail.body]; - newBody[index] = newElement; - - setFileDetail({ ...fileDetail, body: newBody }); - updateHasLocalChanges(true); - - if (focusedElement?.index === index) { - setFocusedElement({ - index, - type: newElement.type, - preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""), - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleGenerateFromElement = useCallback( - (index: number, action: string) => { - if (!fileDetail) return; - - const element = fileDetail.body[index]; - if (!element) return; - - let preview = ""; - switch (element.type) { - case "heading": - case "paragraph": - preview = element.text.slice(0, 50); - break; - case "code": - preview = element.content.slice(0, 50); - break; - case "list": - preview = element.items[0]?.slice(0, 40) || ""; - break; - default: - preview = "Element"; - } - - setFocusedElement({ - index, - type: element.type, - preview: preview + (preview.length >= 50 ? "..." : ""), - }); - - let prompt = ""; - switch (action) { - case "elaborate": - prompt = "Elaborate and expand on this content"; - break; - case "summarize": - prompt = "Summarize this content"; - break; - case "extract_actions": - prompt = "Extract action items from this content"; - break; - } - setSuggestedPrompt(prompt); - }, - [fileDetail] - ); - - // Conflict resolution handlers - const handleConflictReload = useCallback(async () => { - if (fileId) { - clearConflict(); - const detail = await fetchFile(fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - updateHasLocalChanges(false); - } - }, [fileId, clearConflict, fetchFile, updateHasLocalChanges]); - - const handleConflictForceOverwrite = useCallback(async () => { - if (fileId && fileDetail) { - clearConflict(); - const latest = await fetchFile(fileId); - if (latest) { - pendingUpdateRef.current = true; - lastSentVersionRef.current = latest.version; - try { - const result = await editFile(fileId, { body: fileDetail.body, version: latest.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - } - }, [fileId, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]); - - const handleRemoteUpdateRefresh = useCallback(async () => { - if (fileId) { - const detail = await fetchFile(fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - setRemoteUpdate(null); - setRemoteFileData(null); - updateHasLocalChanges(false); - } - }, [fileId, fetchFile, updateHasLocalChanges]); - - const handleRemoteUpdateDismiss = useCallback(() => { - setRemoteUpdate(null); - setRemoteFileData(null); - }, []); - - return ( -
- - -
- {error && ( -
- {error} -
- )} - - {fileId && fileDetail ? ( -
-
- -
-
- setSuggestedPrompt(null)} - /> -
-
- ) : fileId && detailLoading ? ( -
-
Loading...
-
- ) : ( -
-
-

- File not found -

- -
-
- )} -
- - {/* Conflict notification */} - {conflict?.hasConflict && ( - - )} - - {/* Remote update notification */} - {remoteUpdate && ( - - )} -
- ); -} diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx deleted file mode 100644 index ce9ceca..0000000 --- a/makima/frontend/src/routes/contracts.tsx +++ /dev/null @@ -1,885 +0,0 @@ -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, - getRepositorySuggestions, - listContractTypes, -} from "../lib/api"; -import type { - ContractWithRelations, - ContractSummary, - ContractPhase, - ContractStatus, - ContractType, - CreateContractRequest, - RepositorySourceType, - DaemonDirectory, - RepositoryHistoryEntry, - ContractTypeTemplate, -} 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 ( -
- -
-

Loading...

-
-
- ); - } - - // 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 [contractType, setContractType] = useState("simple"); - const [initialPhase, setInitialPhase] = useState("plan"); - 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([]); - const [repoSuggestions, setRepoSuggestions] = useState([]); - const [showRepoSuggestions, setShowRepoSuggestions] = useState(false); - const [contractTypes, setContractTypes] = useState([]); - const [contractTypesLoading, setContractTypesLoading] = useState(false); - const [localOnly, setLocalOnly] = useState(false); - - // Fetch contract types when modal opens - API returns both built-in and custom templates - useEffect(() => { - if (isCreating) { - setContractTypesLoading(true); - - listContractTypes() - .then((res) => { - setContractTypes(res.contractTypes); - setContractTypesLoading(false); - }) - .catch((err) => { - console.error("Failed to fetch contract types:", err); - // Fall back to built-in types - const builtinTypes: ContractTypeTemplate[] = [ - { - id: "simple", - name: "Simple", - description: "Plan \u2192 Execute: Simple workflow with a plan document", - phases: ["plan", "execute"], - defaultPhase: "plan", - isBuiltin: true, - }, - { - id: "specification", - name: "Specification", - description: "Research \u2192 Specify \u2192 Plan \u2192 Execute \u2192 Review: Full specification-driven development with TDD", - phases: ["research", "specify", "plan", "execute", "review"], - defaultPhase: "research", - isBuiltin: true, - }, - { - id: "execute", - name: "Execute", - description: "Execute only: Minimal workflow for immediate task execution", - phases: ["execute"], - defaultPhase: "execute", - isBuiltin: true, - }, - ]; - setContractTypes(builtinTypes); - setContractTypesLoading(false); - }); - } - }, [isCreating]); - - // Fetch repository suggestions when modal opens and repo type changes - useEffect(() => { - if (isCreating && (repoType === "remote" || repoType === "local")) { - getRepositorySuggestions(repoType, undefined, 10) - .then((res) => { - setRepoSuggestions(res.entries); - setShowRepoSuggestions(res.entries.length > 0); - }) - .catch(() => { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - }); - } else { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - } - }, [isCreating, repoType]); - - // Apply a repository suggestion - const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => { - setRepoName(suggestion.name); - if (suggestion.repositoryUrl) { - setRepoUrl(suggestion.repositoryUrl); - } - if (suggestion.localPath) { - setRepoPath(suggestion.localPath); - } - setShowRepoSuggestions(false); - }, []); - - // 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); - - // Get default phase from contract types or fall back to static function - const selectedType = contractTypes.find((t) => t.id === contractType); - const defaultPhaseForType = selectedType?.defaultPhase || (contractType === "simple" ? "plan" : "research"); - const isCustomTemplate = selectedType && !selectedType.isBuiltin; - - const data: CreateContractRequest = { - name: newContractName.trim(), - description: newContractDescription.trim() || undefined, - // For custom templates, send templateId instead of contractType - contractType: isCustomTemplate ? undefined : contractType, - templateId: isCustomTemplate ? contractType : undefined, - initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined, - localOnly: localOnly || 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(""); - setContractType("simple"); - setInitialPhase("plan"); - setRepoType("remote"); - setRepoName(""); - setRepoUrl(""); - setRepoPath(""); - setLocalOnly(false); - navigate(`/contracts/${contract.id}`); - } - } catch (err) { - setCreateError(err instanceof Error ? err.message : "Failed to create contract"); - } - }, [ - newContractName, - newContractDescription, - contractType, - contractTypes, - initialPhase, - repoType, - repoName, - repoUrl, - repoPath, - isRepoValid, - saveContract, - addRemoteRepo, - addLocalRepo, - createManagedRepo, - navigate, - ]); - - const handleCreateCancel = useCallback(() => { - setIsCreating(false); - setNewContractName(""); - setNewContractDescription(""); - setContractType("simple"); - setInitialPhase("plan"); - setRepoType("remote"); - setRepoName(""); - setRepoUrl(""); - setRepoPath(""); - setLocalOnly(false); - 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) => { - if (contractDetail) { - navigate(`/contracts/${contractDetail.id}/files/${fileId}`); - } - }, - [navigate, contractDetail] - ); - - const handleTaskSelect = useCallback( - (taskId: string) => { - navigate(`/exec/${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(`/exec/${task.id}`); - } catch (e) { - console.error("Failed to create task:", e); - alert(e instanceof Error ? e.message : "Failed to create task"); - } - }, - [contractDetail, fetchContract, navigate] - ); - - // Context menu handlers for ContractList - const handleContextMarkComplete = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "completed", version: contract.version }); - }, - [editContract] - ); - - const handleContextMarkActive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "active", version: contract.version }); - }, - [editContract] - ); - - const handleContextArchive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "archived", version: contract.version }); - }, - [editContract] - ); - - const handleContextDelete = useCallback( - async (contract: ContractSummary) => { - if (confirm(`Are you sure you want to delete "${contract.name}"?`)) { - const success = await removeContract(contract.id); - if (success && contract.id === id) { - navigate("/contracts"); - } - } - }, - [removeContract, id, navigate] - ); - - const handleContextGoToSupervisor = useCallback( - (contract: ContractSummary) => { - if (contract.supervisorTaskId) { - navigate(`/exec/${contract.supervisorTaskId}`); - } - }, - [navigate] - ); - - return ( -
- -
- {/* Left: Contract list */} -
- -
- - {/* Right: Detail or Create */} -
- {error && ( -
- {error} -
- )} - - {/* Contract detail, creation form, or empty state */} -
- {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-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - autoFocus - /> -
- - {/* Description */} -
- -