summaryrefslogtreecommitdiff
path: root/makima/frontend/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/hooks')
-rw-r--r--makima/frontend/src/hooks/useFileSubscription.ts154
-rw-r--r--makima/frontend/src/hooks/useFiles.ts23
2 files changed, 177 insertions, 0 deletions
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,