diff options
| author | soryu <soryu@soryu.co> | 2026-01-25 01:32:39 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-25 01:32:39 +0000 |
| commit | 9b47b7273bb2f0124fa08fece39057000b58cf98 (patch) | |
| tree | e23a723fa7e917029818c4a6d012ca57d935898f | |
| parent | 579c983d3efb8f1414ffb45b9e031f741cce5f76 (diff) | |
| download | soryu-9b47b7273bb2f0124fa08fece39057000b58cf98.tar.gz soryu-9b47b7273bb2f0124fa08fece39057000b58cf98.zip | |
feat: Add contract-scoped file route /contracts/:id/files/:fileId
- Create ContractFilePage component for viewing files within contract context
- Add route for /contracts/:id/files/:fileId in main.tsx
- Update handleFileSelect in contracts.tsx to navigate to contract-scoped file URL
- File viewer now has "Back to Contract" navigation instead of standalone /files
This allows files accessed from a contract to maintain context and return
to the contract page when going back.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/frontend/src/main.tsx | 9 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contract-file.tsx | 659 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 9 |
3 files changed, 675 insertions, 2 deletions
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 19f02d1..9a6e65e 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -17,6 +17,7 @@ import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; import SettingsPage from "./routes/settings"; +import ContractFilePage from "./routes/contract-file"; createRoot(document.getElementById("root")!).render( <StrictMode> @@ -70,6 +71,14 @@ createRoot(document.getElementById("root")!).render( } /> <Route + path="/contracts/:id/files/:fileId" + element={ + <ProtectedRoute> + <ContractFilePage /> + </ProtectedRoute> + } + /> + <Route path="/workflow" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/contract-file.tsx b/makima/frontend/src/routes/contract-file.tsx new file mode 100644 index 0000000..9ed25ed --- /dev/null +++ b/makima/frontend/src/routes/contract-file.tsx @@ -0,0 +1,659 @@ +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 index 6acda29..b202a8f 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -375,9 +375,14 @@ function ContractsPageContent() { // File/task navigation handlers const handleFileSelect = useCallback( (fileId: string) => { - navigate(`/files/${fileId}`); + if (contractDetail) { + navigate(`/contracts/${contractDetail.id}/files/${fileId}`); + } else { + // Fallback to standalone route if no contract context + navigate(`/files/${fileId}`); + } }, - [navigate] + [navigate, contractDetail] ); const handleTaskSelect = useCallback( |
