From 72c2590571104b8d10e3f72d7a5b984d0b520c51 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 23 Dec 2025 22:20:52 +0000 Subject: Add conflict notification and file update WS endpoint --- .../src/components/files/ConflictNotification.tsx | 47 +++++++ .../src/components/files/UpdateNotification.tsx | 43 ++++++ makima/frontend/src/hooks/useFileSubscription.ts | 154 +++++++++++++++++++++ makima/frontend/src/hooks/useFiles.ts | 23 +++ makima/frontend/src/lib/api.ts | 30 ++++ makima/frontend/src/routes/files.tsx | 133 ++++++++++++++++-- makima/frontend/tsconfig.tsbuildinfo | 2 +- 7 files changed, 421 insertions(+), 11 deletions(-) create mode 100644 makima/frontend/src/components/files/ConflictNotification.tsx create mode 100644 makima/frontend/src/components/files/UpdateNotification.tsx create mode 100644 makima/frontend/src/hooks/useFileSubscription.ts (limited to 'makima/frontend') diff --git a/makima/frontend/src/components/files/ConflictNotification.tsx b/makima/frontend/src/components/files/ConflictNotification.tsx new file mode 100644 index 0000000..5d54c32 --- /dev/null +++ b/makima/frontend/src/components/files/ConflictNotification.tsx @@ -0,0 +1,47 @@ +interface ConflictNotificationProps { + onReload: () => void; + onForceOverwrite: () => void; + onDismiss: () => void; +} + +export function ConflictNotification({ + onReload, + onForceOverwrite, + onDismiss, +}: ConflictNotificationProps) { + return ( +
+
+
!
+
+

+ Conflict Detected +

+

+ This file was modified elsewhere. Your changes could not be saved. +

+
+ + + +
+
+
+
+ ); +} diff --git a/makima/frontend/src/components/files/UpdateNotification.tsx b/makima/frontend/src/components/files/UpdateNotification.tsx new file mode 100644 index 0000000..92b2b15 --- /dev/null +++ b/makima/frontend/src/components/files/UpdateNotification.tsx @@ -0,0 +1,43 @@ +interface UpdateNotificationProps { + updatedBy: "user" | "llm" | "system"; + onRefresh: () => void; + onDismiss: () => void; +} + +export function UpdateNotification({ + updatedBy, + onRefresh, + onDismiss, +}: UpdateNotificationProps) { + const source = updatedBy === "llm" ? "AI assistant" : "another session"; + + return ( +
+
+
i
+
+

+ File Updated +

+

+ This file was updated by {source}. +

+
+ + +
+
+
+
+ ); +} diff --git a/makima/frontend/src/hooks/useFileSubscription.ts b/makima/frontend/src/hooks/useFileSubscription.ts new file mode 100644 index 0000000..7260b96 --- /dev/null +++ b/makima/frontend/src/hooks/useFileSubscription.ts @@ -0,0 +1,154 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { FILE_SUBSCRIBE_ENDPOINT } from "../lib/api"; + +export interface FileUpdateEvent { + fileId: string; + version: number; + updatedFields: string[]; + updatedBy: "user" | "llm" | "system"; +} + +interface UseFileSubscriptionOptions { + fileId: string | null; + onUpdate?: (event: FileUpdateEvent) => void; + onError?: (error: string) => void; +} + +export function useFileSubscription(options: UseFileSubscriptionOptions) { + const { fileId, onUpdate, onError } = options; + const [connected, setConnected] = useState(false); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const subscribedFileRef = useRef(null); + + // Store callbacks in refs to avoid re-connecting when callbacks change + const callbacksRef = useRef({ onUpdate, onError }); + useEffect(() => { + callbacksRef.current = { onUpdate, onError }; + }, [onUpdate, onError]); + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + try { + const ws = new WebSocket(FILE_SUBSCRIBE_ENDPOINT); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + // Re-subscribe if we had a subscription + if (subscribedFileRef.current) { + ws.send( + JSON.stringify({ + type: "subscribe", + fileId: subscribedFileRef.current, + }) + ); + } + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + + if (message.type === "fileUpdated") { + callbacksRef.current.onUpdate?.({ + fileId: message.fileId, + version: message.version, + updatedFields: message.updatedFields, + updatedBy: message.updatedBy, + }); + } else if (message.type === "error") { + callbacksRef.current.onError?.(message.message); + } + } catch (e) { + console.error("Failed to parse file subscription message:", e); + } + }; + + ws.onerror = () => { + callbacksRef.current.onError?.("WebSocket connection error"); + }; + + ws.onclose = () => { + setConnected(false); + wsRef.current = null; + + // Attempt reconnection after 3 seconds if we still have a subscription + if (subscribedFileRef.current) { + reconnectTimeoutRef.current = window.setTimeout(() => { + connect(); + }, 3000); + } + }; + } catch (e) { + callbacksRef.current.onError?.( + e instanceof Error ? e.message : "Failed to connect" + ); + } + }, []); + + const subscribe = useCallback( + (id: string) => { + subscribedFileRef.current = id; + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "subscribe", + fileId: id, + }) + ); + } else { + connect(); + } + }, + [connect] + ); + + const unsubscribe = useCallback(() => { + if ( + subscribedFileRef.current && + wsRef.current?.readyState === WebSocket.OPEN + ) { + wsRef.current.send( + JSON.stringify({ + type: "unsubscribe", + fileId: subscribedFileRef.current, + }) + ); + } + subscribedFileRef.current = null; + }, []); + + // Auto-subscribe when fileId changes + useEffect(() => { + if (fileId) { + subscribe(fileId); + } else { + unsubscribe(); + } + + return () => { + unsubscribe(); + }; + }, [fileId, subscribe, unsubscribe]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, []); + + return { + connected, + subscribe, + unsubscribe, + }; +} diff --git a/makima/frontend/src/hooks/useFiles.ts b/makima/frontend/src/hooks/useFiles.ts index aacbb6a..1998357 100644 --- a/makima/frontend/src/hooks/useFiles.ts +++ b/makima/frontend/src/hooks/useFiles.ts @@ -5,16 +5,24 @@ import { createFile, updateFile, deleteFile, + VersionConflictError, type FileSummary, type FileDetail, type CreateFileRequest, type UpdateFileRequest, } from "../lib/api"; +export interface ConflictState { + hasConflict: boolean; + expectedVersion: number; + actualVersion: number; +} + export function useFiles() { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [conflict, setConflict] = useState(null); const fetchFiles = useCallback(async () => { setLoading(true); @@ -60,11 +68,20 @@ export function useFiles() { const editFile = useCallback( async (id: string, data: UpdateFileRequest): Promise => { setError(null); + setConflict(null); try { const file = await updateFile(id, data); await fetchFiles(); // Refresh list return file; } catch (e) { + if (e instanceof VersionConflictError) { + setConflict({ + hasConflict: true, + expectedVersion: e.expectedVersion, + actualVersion: e.actualVersion, + }); + return null; + } setError(e instanceof Error ? e.message : "Failed to update file"); return null; } @@ -72,6 +89,10 @@ export function useFiles() { [fetchFiles] ); + const clearConflict = useCallback(() => { + setConflict(null); + }, []); + const removeFile = useCallback( async (id: string): Promise => { setError(null); @@ -96,6 +117,8 @@ export function useFiles() { files, loading, error, + conflict, + clearConflict, fetchFiles, fetchFile, saveFile, diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 6f7071d..f1e3a9f 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -34,6 +34,7 @@ const env = detectEnvironment(); export const API_BASE = API_CONFIG[env].http; export const WS_BASE = API_CONFIG[env].ws; export const LISTEN_ENDPOINT = `${WS_BASE}/api/v1/listen`; +export const FILE_SUBSCRIBE_ENDPOINT = `${WS_BASE}/api/v1/files/subscribe`; export function getEnvironment(): Environment { return env; @@ -71,6 +72,7 @@ export interface FileSummary { description: string | null; transcriptCount: number; duration: number | null; + version: number; createdAt: string; updatedAt: string; } @@ -84,6 +86,7 @@ export interface FileDetail { location: string | null; summary: string | null; body: BodyElement[]; + version: number; createdAt: string; updatedAt: string; } @@ -106,6 +109,27 @@ export interface UpdateFileRequest { transcript?: TranscriptEntry[]; summary?: string; body?: BodyElement[]; + version?: number; +} + +// Conflict error types +export interface ConflictErrorResponse { + code: "VERSION_CONFLICT"; + message: string; + expectedVersion: number; + actualVersion: number; +} + +export class VersionConflictError extends Error { + expectedVersion: number; + actualVersion: number; + + constructor(conflict: ConflictErrorResponse) { + super(conflict.message); + this.name = "VersionConflictError"; + this.expectedVersion = conflict.expectedVersion; + this.actualVersion = conflict.actualVersion; + } } // Available LLM models @@ -170,6 +194,12 @@ export async function updateFile( headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); + + if (res.status === 409) { + const conflict = (await res.json()) as ConflictErrorResponse; + throw new VersionConflictError(conflict); + } + if (!res.ok) { throw new Error(`Failed to update file: ${res.statusText}`); } diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 79544c5..423baa1 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -1,33 +1,71 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { useParams, useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { FileList } from "../components/files/FileList"; import { FileDetail } 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 { + useFileSubscription, + type FileUpdateEvent, +} from "../hooks/useFileSubscription"; import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; export default function FilesPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { files, loading, error, fetchFile, editFile, removeFile, saveFile } = useFiles(); + const { files, loading, error, conflict, clearConflict, fetchFile, editFile, removeFile, saveFile } = useFiles(); const [fileDetail, setFileDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [creating, setCreating] = useState(false); + const [remoteUpdate, setRemoteUpdate] = useState(null); + const [hasLocalChanges, setHasLocalChanges] = useState(false); + const pendingUpdateRef = useRef(false); // Load file detail when URL has an id useEffect(() => { if (id) { setDetailLoading(true); + setHasLocalChanges(false); fetchFile(id).then((detail) => { setFileDetail(detail); setDetailLoading(false); }); } else { setFileDetail(null); + setHasLocalChanges(false); } }, [id, fetchFile]); + // Handle file update events from WebSocket + const handleFileUpdate = useCallback( + async (event: FileUpdateEvent) => { + // Ignore our own updates + if (pendingUpdateRef.current) { + pendingUpdateRef.current = false; + return; + } + + // If no local changes, auto-refresh + if (!hasLocalChanges) { + const detail = await fetchFile(event.fileId); + setFileDetail(detail); + } else { + // Show notification about remote update + setRemoteUpdate(event); + } + }, + [hasLocalChanges, fetchFile] + ); + + // Subscribe to file updates + useFileSubscription({ + fileId: id || null, + onUpdate: handleFileUpdate, + }); + const handleSelectFile = useCallback( (fileId: string) => { navigate(`/files/${fileId}`); @@ -53,11 +91,15 @@ export default function FilesPage() { const handleSave = useCallback( async (fileId: string, name: string, description: string) => { - await editFile(fileId, { name, description }); - const detail = await fetchFile(fileId); - setFileDetail(detail); + if (!fileDetail) return; + pendingUpdateRef.current = true; + const result = await editFile(fileId, { name, description, version: fileDetail.version }); + if (result) { + setFileDetail(result); + setHasLocalChanges(false); + } }, - [editFile, fetchFile] + [editFile, fileDetail] ); const handleBodyUpdate = useCallback( @@ -85,9 +127,15 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); + setHasLocalChanges(true); - // Save to backend - await editFile(id, { body: newBody }); + // Save to backend with version for optimistic locking + pendingUpdateRef.current = true; + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + setFileDetail(result); + setHasLocalChanges(false); + } } }, [fileDetail, id, editFile] @@ -106,9 +154,15 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); + setHasLocalChanges(true); - // Save to backend - await editFile(id, { body: newBody }); + // Save to backend with version for optimistic locking + pendingUpdateRef.current = true; + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + setFileDetail(result); + setHasLocalChanges(false); + } } }, [fileDetail, id, editFile] @@ -130,6 +184,47 @@ export default function FilesPage() { } }, [creating, saveFile, navigate]); + // Conflict resolution handlers + const handleConflictReload = useCallback(async () => { + if (id) { + clearConflict(); + const detail = await fetchFile(id); + setFileDetail(detail); + setHasLocalChanges(false); + } + }, [id, clearConflict, fetchFile]); + + const handleConflictForceOverwrite = useCallback(async () => { + if (id && fileDetail) { + clearConflict(); + // Fetch latest version first + const latest = await fetchFile(id); + if (latest) { + // Retry with latest version + pendingUpdateRef.current = true; + const result = await editFile(id, { body: fileDetail.body, version: latest.version }); + if (result) { + setFileDetail(result); + setHasLocalChanges(false); + } + } + } + }, [id, fileDetail, clearConflict, fetchFile, editFile]); + + // Remote update handlers + const handleRemoteUpdateRefresh = useCallback(async () => { + if (id) { + const detail = await fetchFile(id); + setFileDetail(detail); + setRemoteUpdate(null); + setHasLocalChanges(false); + } + }, [id, fetchFile]); + + const handleRemoteUpdateDismiss = useCallback(() => { + setRemoteUpdate(null); + }, []); + return (
@@ -172,6 +267,24 @@ export default function FilesPage() { /> )} + + {/* Conflict notification */} + {conflict?.hasConflict && ( + + )} + + {/* Remote update notification */} + {remoteUpdate && ( + + )}
); } diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index b2542f9..bda8af0 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file -- cgit v1.2.3