diff options
Diffstat (limited to 'makima/frontend/src/routes')
| -rw-r--r-- | makima/frontend/src/routes/contract-file.tsx | 659 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 885 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 16 | ||||
| -rw-r--r-- | makima/frontend/src/routes/tmp.tsx | 9 |
4 files changed, 13 insertions, 1556 deletions
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 ( - <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; - } - - // Render the file page with contract context - return <ContractAwareFilesPage contractId={contractId} fileId={fileId} />; -} - -// 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<FileDetailType | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); - const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); - const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); - const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); - const pendingUpdateRef = useRef(false); - const lastSentVersionRef = useRef<number | null>(null); - const lastSavedVersionRef = useRef<number | null>(null); - const hasLocalChangesRef = useRef(false); - const isActivelyEditingRef = useRef(false); - const currentVersionRef = useRef<number | null>(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 ( - <div className="relative z-10 h-screen flex flex-col overflow-hidden"> - <Masthead showTicker={false} showNav /> - - <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col"> - {error && ( - <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0"> - {error} - </div> - )} - - {fileId && fileDetail ? ( - <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> - <div className="flex-1 min-h-0 overflow-hidden"> - <FileDetail - file={fileDetail} - loading={detailLoading} - onBack={handleBack} - onSave={handleSave} - onDelete={handleDelete} - onBodyElementUpdate={handleBodyElementUpdate} - onBodyReorder={handleBodyReorder} - onBodyElementDelete={handleBodyElementDelete} - onBodyElementDuplicate={handleBodyElementDuplicate} - onConvertElement={handleConvertElement} - onGenerateFromElement={handleGenerateFromElement} - onEditingChange={updateIsActivelyEditing} - hasPendingRemoteUpdate={!!remoteUpdate} - onOverwrite={handleRemoteUpdateDismiss} - focusedElement={focusedElement} - onFocusElement={handleFocusElement} - versions={versions} - versionsLoading={versionsLoading} - selectedVersion={selectedVersion} - loadingVersion={loadingVersion} - restoring={restoring} - onSelectVersion={fetchVersion} - onRestoreVersion={handleRestoreVersion} - onClearVersionSelection={clearSelectedVersion} - /> - </div> - <div className="shrink-0"> - <CliInput - fileId={fileId} - onUpdate={handleBodyUpdate} - focusedElement={focusedElement} - onClearFocus={handleClearFocus} - suggestedPrompt={suggestedPrompt} - onClearSuggestedPrompt={() => setSuggestedPrompt(null)} - /> - </div> - </div> - ) : fileId && detailLoading ? ( - <div className="panel h-full flex items-center justify-center"> - <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> - </div> - ) : ( - <div className="panel h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - File not found - </p> - <button - onClick={handleBack} - className="px-4 py-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" - > - ← Back to Contract - </button> - </div> - </div> - )} - </main> - - {/* Conflict notification */} - {conflict?.hasConflict && ( - <ConflictNotification - onReload={handleConflictReload} - onForceOverwrite={handleConflictForceOverwrite} - onDismiss={clearConflict} - /> - )} - - {/* Remote update notification */} - {remoteUpdate && ( - <UpdateNotification - updatedBy={remoteUpdate.updatedBy} - localBody={fileDetail?.body || []} - remoteBody={remoteFileData?.body || []} - onRefresh={handleRemoteUpdateRefresh} - onDismiss={handleRemoteUpdateDismiss} - /> - )} - </div> - ); -} 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 ( - <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 [contractType, setContractType] = useState<ContractType>("simple"); - const [initialPhase, setInitialPhase] = useState<ContractPhase>("plan"); - 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[]>([]); - const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showRepoSuggestions, setShowRepoSuggestions] = useState(false); - const [contractTypes, setContractTypes] = useState<ContractTypeTemplate[]>([]); - 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 ( - <div className="relative z-10 h-screen flex flex-col overflow-hidden bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> - {/* Left: Contract list */} - <div className="w-[350px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> - <ContractList - contracts={contracts} - loading={loading} - onSelect={handleSelect} - onCreate={handleCreate} - selectedId={id} - onMarkComplete={handleContextMarkComplete} - onMarkActive={handleContextMarkActive} - onArchive={handleContextArchive} - onDelete={handleContextDelete} - onGoToSupervisor={handleContextGoToSupervisor} - /> - </div> - - {/* Right: Detail or Create */} - <div className="flex-1 overflow-hidden flex flex-col min-h-0"> - {error && ( - <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> - {error} - </div> - )} - - {/* Contract detail, creation form, or empty state */} - <div className="flex-1 min-h-0 overflow-hidden"> - {isCreating ? ( - <div className="p-4 max-w-lg overflow-y-auto h-full bg-[#0a1628]"> - <h3 className="font-mono text-[10px] text-[#9bc3ff] uppercase tracking-wide 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 text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide 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-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - autoFocus - /> - </div> - - {/* Description */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide 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-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc] resize-none" - /> - </div> - - {/* Contract Type */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Contract Type - </label> - {contractTypesLoading ? ( - <div className="flex items-center justify-center py-4"> - <span className="font-mono text-xs text-[#8b949e]">Loading contract types...</span> - </div> - ) : ( - <> - <div className="flex gap-2"> - {contractTypes.map((type) => ( - <button - key={type.id} - type="button" - onClick={() => { - setContractType(type.id as ContractType); - setInitialPhase(type.defaultPhase as ContractPhase); - }} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - contractType === type.id - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - {type.name} - </button> - ))} - </div> - <p className="mt-1 font-mono text-xs text-[#8b949e]"> - {contractTypes.find((t) => t.id === contractType)?.description || - "Select a contract type"} - </p> - </> - )} - </div> - - {/* Starting Phase */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide 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-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - > - {(() => { - const template = contractTypes.find((t) => t.id === contractType); - return (template?.phases || []).map((phase) => { - const displayName = template?.phaseNames?.[phase] || (phase.charAt(0).toUpperCase() + phase.slice(1)); - return ( - <option key={phase} value={phase}> - {displayName} - </option> - ); - }); - })()} - </select> - <p className="mt-1 font-mono text-xs text-[#8b949e]"> - {contractType === "simple" - ? "Start in Plan to define what to build, or Execute if already planned" - : "Skip earlier phases if you already have requirements defined"} - </p> - </div> - - {/* Local-Only Mode */} - <div className="space-y-2"> - <div className="flex items-center space-x-3"> - <button - type="button" - onClick={() => setLocalOnly(!localOnly)} - className={`w-5 h-5 flex items-center justify-center border transition-colors ${ - localOnly - ? "bg-[#0f3c78] border-[#75aafc] text-[#dbe7ff]" - : "bg-[#0d1b2d] border-[rgba(117,170,252,0.2)] text-transparent" - }`} - > - {localOnly && ( - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="3" - strokeLinecap="round" - strokeLinejoin="round" - className="w-3 h-3" - > - <polyline points="20 6 9 17 4 12" /> - </svg> - )} - </button> - <label - className="font-mono text-sm text-[#dbe7ff] cursor-pointer select-none" - onClick={() => setLocalOnly(!localOnly)} - > - Local-Only Mode - </label> - </div> - <p className="font-mono text-xs text-[#8b949e] pl-8"> - When enabled, tasks won't automatically push to remote or create PRs. - Use patch files to export changes. - </p> - </div> - - {/* Repository Configuration */} - <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide 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-[rgba(117,170,252,0.2)] 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-[rgba(117,170,252,0.2)] 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-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - Managed - </button> - </div> - - {/* Repository suggestions */} - {showRepoSuggestions && repoSuggestions.length > 0 && ( - <div className="mb-3"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Recent Repositories - </label> - <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> - {repoSuggestions.map((suggestion) => ( - <button - key={suggestion.id} - type="button" - onClick={() => applyRepoSuggestion(suggestion)} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="flex items-center justify-between"> - <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> - <span className="text-[10px] text-[#556677] ml-2"> - {suggestion.useCount}× - </span> - </div> - <div className="text-[10px] text-[#556677] truncate"> - {repoType === "local" ? suggestion.localPath : suggestion.repositoryUrl} - </div> - </button> - ))} - </div> - </div> - )} - - {/* Repository name */} - <div className="mb-3"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide 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-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - /> - </div> - - {/* Repository URL (for remote) */} - {repoType === "remote" && ( - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide 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-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - /> - </div> - )} - - {/* Repository path (for local) */} - {repoType === "local" && ( - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide 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-[rgba(117,170,252,0.2)] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Create - </button> - </div> - </div> - </div> - ) : 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> - </div> - </main> - </div> - ); -} diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 7b0a89b..a3ea969 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -1530,13 +1530,17 @@ export default function DocumentDirectivesPage() { : null; return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + // h-screen + overflow-hidden so the page itself never scrolls; the + // sidebar and editor pane each manage their own scroll via flex-1 + // children with overflow-y-auto. Previously we set + // height: calc(100vh - 80px) on <main>, which assumed an 80px masthead + // and quietly clipped content when the masthead was taller (or pushed + // the page below the viewport on shorter screens, which made the + // whole page scroll instead of the sidebar/editor independently). + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> - <main - className="flex-1 flex overflow-hidden" - style={{ height: "calc(100vh - 80px)" }} - > - {/* Left: file-tree sidebar */} + <main className="flex-1 flex min-h-0 overflow-hidden"> + {/* Left: file-tree sidebar — independent scroll. */} <div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> <DocumentSidebar directives={directives} diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx index 69f13a2..c0c7365 100644 --- a/makima/frontend/src/routes/tmp.tsx +++ b/makima/frontend/src/routes/tmp.tsx @@ -53,7 +53,7 @@ export default function TmpTaskPage() { if (authLoading) { return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> <main className="flex-1 flex items-center justify-center"> <p className="text-[#7788aa] font-mono text-sm">Loading...</p> @@ -63,12 +63,9 @@ export default function TmpTaskPage() { } return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> - <main - className="flex-1 flex flex-col overflow-hidden" - style={{ height: "calc(100vh - 80px)" }} - > + <main className="flex-1 flex flex-col min-h-0 overflow-hidden"> {/* Breadcrumb echoing the document-mode header style. */} <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]"> |
