summaryrefslogtreecommitdiff
path: root/makima/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend')
-rw-r--r--makima/frontend/src/components/files/ConflictNotification.tsx47
-rw-r--r--makima/frontend/src/components/files/UpdateNotification.tsx43
-rw-r--r--makima/frontend/src/hooks/useFileSubscription.ts154
-rw-r--r--makima/frontend/src/hooks/useFiles.ts23
-rw-r--r--makima/frontend/src/lib/api.ts30
-rw-r--r--makima/frontend/src/routes/files.tsx133
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
7 files changed, 421 insertions, 11 deletions
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 (
+ <div className="fixed bottom-4 right-4 max-w-md p-4 bg-[#1a2332] border border-yellow-500/50 shadow-lg z-50">
+ <div className="flex items-start gap-3">
+ <div className="text-yellow-500 text-xl font-bold">!</div>
+ <div className="flex-1">
+ <h3 className="font-mono text-sm text-[#9bc3ff] font-semibold mb-1">
+ Conflict Detected
+ </h3>
+ <p className="font-mono text-xs text-white/70 mb-3">
+ This file was modified elsewhere. Your changes could not be saved.
+ </p>
+ <div className="flex gap-2">
+ <button
+ onClick={onReload}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Reload Latest
+ </button>
+ <button
+ onClick={onForceOverwrite}
+ className="px-3 py-1 font-mono text-xs text-red-400 border border-red-400/25 hover:border-red-400/50 transition-colors"
+ >
+ Force Save
+ </button>
+ <button
+ onClick={onDismiss}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+ >
+ Dismiss
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
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 (
+ <div className="fixed bottom-4 right-4 max-w-md p-4 bg-[#1a2332] border border-[#3f6fb3]/50 shadow-lg z-50">
+ <div className="flex items-start gap-3">
+ <div className="text-[#75aafc] text-xl font-bold">i</div>
+ <div className="flex-1">
+ <h3 className="font-mono text-sm text-[#9bc3ff] font-semibold mb-1">
+ File Updated
+ </h3>
+ <p className="font-mono text-xs text-white/70 mb-3">
+ This file was updated by {source}.
+ </p>
+ <div className="flex gap-2">
+ <button
+ onClick={onRefresh}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Refresh Now
+ </button>
+ <button
+ onClick={onDismiss}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+ >
+ Dismiss
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
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<WebSocket | null>(null);
+ const reconnectTimeoutRef = useRef<number | null>(null);
+ const subscribedFileRef = useRef<string | null>(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<FileSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
+ const [conflict, setConflict] = useState<ConflictState | null>(null);
const fetchFiles = useCallback(async () => {
setLoading(true);
@@ -60,11 +68,20 @@ export function useFiles() {
const editFile = useCallback(
async (id: string, data: UpdateFileRequest): Promise<FileDetail | null> => {
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<boolean> => {
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<FileDetailType | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [creating, setCreating] = useState(false);
+ const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(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 (
<div className="relative z-10 h-screen flex flex-col overflow-hidden">
<Masthead showTicker={false} showNav />
@@ -172,6 +267,24 @@ export default function FilesPage() {
/>
)}
</main>
+
+ {/* Conflict notification */}
+ {conflict?.hasConflict && (
+ <ConflictNotification
+ onReload={handleConflictReload}
+ onForceOverwrite={handleConflictForceOverwrite}
+ onDismiss={clearConflict}
+ />
+ )}
+
+ {/* Remote update notification */}
+ {remoteUpdate && (
+ <UpdateNotification
+ updatedBy={remoteUpdate.updatedBy}
+ onRefresh={handleRemoteUpdateRefresh}
+ onDismiss={handleRemoteUpdateDismiss}
+ />
+ )}
</div>
);
}
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