summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-25 01:32:39 +0000
committersoryu <soryu@soryu.co>2026-01-25 01:32:39 +0000
commit9b47b7273bb2f0124fa08fece39057000b58cf98 (patch)
treee23a723fa7e917029818c4a6d012ca57d935898f
parent579c983d3efb8f1414ffb45b9e031f741cce5f76 (diff)
downloadsoryu-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.tsx9
-rw-r--r--makima/frontend/src/routes/contract-file.tsx659
-rw-r--r--makima/frontend/src/routes/contracts.tsx9
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"
+ >
+ &larr; 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(