summaryrefslogtreecommitdiff
path: root/makima/frontend/src/hooks/useFileSubscription.ts
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-23 22:20:52 +0000
committersoryu <soryu@soryu.co>2025-12-23 22:20:52 +0000
commit72c2590571104b8d10e3f72d7a5b984d0b520c51 (patch)
tree735aa03056a44a93b9abdf915545ad034ee2b597 /makima/frontend/src/hooks/useFileSubscription.ts
parentf5222a7ae5ade5589436778cb01fc0abe625b3c3 (diff)
downloadsoryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.tar.gz
soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.zip
Add conflict notification and file update WS endpoint
Diffstat (limited to 'makima/frontend/src/hooks/useFileSubscription.ts')
-rw-r--r--makima/frontend/src/hooks/useFileSubscription.ts154
1 files changed, 154 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,
+ };
+}