diff --git a/__tests__/components/editor/HealthCheckPanel.test.tsx b/__tests__/components/editor/HealthCheckPanel.test.tsx index efc25052..3fb07994 100644 --- a/__tests__/components/editor/HealthCheckPanel.test.tsx +++ b/__tests__/components/editor/HealthCheckPanel.test.tsx @@ -13,7 +13,7 @@ vi.mock("@/lib/api/lint", () => ({ getLintConfig: vi.fn(), getLevels: vi.fn(), }, - createLintWebSocket: vi.fn(() => ({ close: vi.fn() })), + createLintWebSocket: vi.fn(() => ({ close: vi.fn(), addEventListener: vi.fn() })), })); vi.mock("@/lib/api/quality", () => ({ @@ -133,7 +133,7 @@ describe("HealthCheckPanel", () => { checked_at: "", duration_ms: 0, }); - mockCreateLintWebSocket.mockReturnValue({ close: vi.fn() } as unknown as WebSocket); + mockCreateLintWebSocket.mockReturnValue({ close: vi.fn(), addEventListener: vi.fn() } as unknown as WebSocket); mockQualityApi.getLatestDuplicates.mockResolvedValue({ clusters: [], threshold: 0.85, @@ -1809,7 +1809,7 @@ describe("HealthCheckPanel", () => { mockCreateLintWebSocket.mockImplementation( (_projectId: string, onMessage: (msg: LintWebSocketMessage) => void) => { capturedLintOnMessage = onMessage; - return { close: vi.fn() } as unknown as WebSocket; + return { close: vi.fn(), addEventListener: vi.fn() } as unknown as WebSocket; } ); @@ -2077,6 +2077,76 @@ describe("HealthCheckPanel", () => { vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { cb(0); return 0; }); }); + describe("onWsStatusChange callbacks", () => { + it("calls onWsStatusChange('disconnected') immediately when isOpen is false", () => { + const onWsStatusChange = vi.fn(); + setup({ isOpen: false, onWsStatusChange }); + expect(onWsStatusChange).toHaveBeenCalledWith("disconnected"); + }); + + it("calls onWsStatusChange('connecting') then 'connected' when WS opens", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const onWsStatusChange = vi.fn(); + const captured: { openListener: (() => void) | null } = { openListener: null }; + mockCreateLintWebSocket.mockImplementation( + (_projectId: string, _onMessage: (msg: LintWebSocketMessage) => void) => { + const ws = { close: vi.fn(), addEventListener: vi.fn((event: string, cb: () => void) => { + if (event === "open") captured.openListener = cb; + }) }; + return ws as unknown as WebSocket; + } + ); + + setup({ onWsStatusChange }); + expect(onWsStatusChange).toHaveBeenCalledWith("connecting"); + + await vi.advanceTimersByTimeAsync(110); + captured.openListener?.(); + expect(onWsStatusChange).toHaveBeenCalledWith("connected"); + } finally { + vi.useRealTimers(); + } + }); + + it("calls onWsStatusChange('disconnected') when WS closes", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const onWsStatusChange = vi.fn(); + const captured: { closeListener: (() => void) | null } = { closeListener: null }; + mockCreateLintWebSocket.mockImplementation( + (_projectId: string, _onMessage: (msg: LintWebSocketMessage) => void) => { + const ws = { close: vi.fn(), addEventListener: vi.fn((event: string, cb: () => void) => { + if (event === "close") captured.closeListener = cb; + }) }; + return ws as unknown as WebSocket; + } + ); + + setup({ onWsStatusChange }); + await vi.advanceTimersByTimeAsync(110); + captured.closeListener?.(); + expect(onWsStatusChange).toHaveBeenCalledWith("disconnected"); + } finally { + vi.useRealTimers(); + } + }); + + it("calls onWsStatusChange('disconnected') on cleanup", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const onWsStatusChange = vi.fn(); + const { unmount } = setup({ onWsStatusChange }); + await vi.advanceTimersByTimeAsync(110); + onWsStatusChange.mockClear(); + unmount(); + expect(onWsStatusChange).toHaveBeenCalledWith("disconnected"); + } finally { + vi.useRealTimers(); + } + }); + }); + it("does not reopen the lint WebSocket when the issue filter changes", async () => { // The fetchDataRef pattern keeps `filter` out of the WS effect's deps so // changing filters re-issues HTTP for filtered issues without tearing diff --git a/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx b/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx index 1c41d669..06e50949 100644 --- a/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx +++ b/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx @@ -1349,9 +1349,6 @@ describe("DeveloperEditorLayout", () => { }); it("preserves the selection store on unmount so side-page Back-to-project links can read it", () => { - // Regression: editor used to clear the store on unmount, racing with - // side-page navigation — by the time settings/PRs/etc rendered, the - // store was empty and useProjectHomeHref dropped the selection. const { unmount } = render( { expect(useSelectionStore.getState().type).toBe("class"); }); }); + + describe("HealthCheckPanel integration", () => { + it("does not render HealthCheckPanel by default", () => { + render(); + expect(screen.queryByTestId("health-check-panel")).toBeNull(); + }); + + it("renders HealthCheckPanel when showHealthCheck is true", () => { + render(); + expect(screen.getByTestId("health-check-panel")).toBeDefined(); + }); + + it("passes onLintWsStatusChange through to HealthCheckPanel", () => { + const onLintWsStatusChange = vi.fn(); + render(); + expect(screen.getByTestId("health-check-panel")).toBeDefined(); + }); + }); }); diff --git a/__tests__/lib/hooks/useCollaborationStatus.test.ts b/__tests__/lib/hooks/useCollaborationStatus.test.ts index 724dfcb5..0dc9bd86 100644 --- a/__tests__/lib/hooks/useCollaborationStatus.test.ts +++ b/__tests__/lib/hooks/useCollaborationStatus.test.ts @@ -1,153 +1,30 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; import { useCollaborationStatus } from "@/lib/hooks/useCollaborationStatus"; -// Mock WebSocket -class MockWebSocket { - static instances: MockWebSocket[] = []; - url: string; - onopen: (() => void) | null = null; - onclose: (() => void) | null = null; - onerror: (() => void) | null = null; - onmessage: ((event: { data: string }) => void) | null = null; - close = vi.fn(() => { - this.onclose?.(); - }); - - constructor(url: string) { - this.url = url; - MockWebSocket.instances.push(this); - } -} - -beforeEach(() => { - vi.useFakeTimers(); - MockWebSocket.instances = []; - vi.stubGlobal("WebSocket", MockWebSocket); -}); - -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - describe("useCollaborationStatus", () => { - it("returns disconnected status when not enabled", () => { + it("returns disabled status (stub — collab endpoint not yet implemented)", () => { const { result } = renderHook(() => - useCollaborationStatus({ projectId: "proj-1", enabled: false }), + useCollaborationStatus({ projectId: "proj-1" }), ); - expect(result.current.status).toBe("disconnected"); + expect(result.current.status).toBe("disabled"); expect(result.current.isConnected).toBe(false); }); - it("returns the correct endpoint path", () => { + it("returns the collab endpoint path", () => { const { result } = renderHook(() => - useCollaborationStatus({ projectId: "proj-1", enabled: false }), + useCollaborationStatus({ projectId: "proj-1" }), ); - expect(result.current.endpoint).toBe("/api/v1/projects/proj-1/lint/ws"); + expect(result.current.endpoint).toBe("/api/v1/collab/ws"); }); it("returns the purpose description", () => { const { result } = renderHook(() => - useCollaborationStatus({ projectId: "proj-1", enabled: false }), - ); - - expect(result.current.purpose).toBe("Real-time lint status updates"); - }); - - it("constructs correct WebSocket URL", () => { - renderHook(() => - useCollaborationStatus({ projectId: "proj-1", enabled: true }), + useCollaborationStatus({ projectId: "proj-1" }), ); - // The hook has a 100ms delay before connecting - act(() => { - vi.advanceTimersByTime(100); - }); - - expect(MockWebSocket.instances.length).toBeGreaterThan(0); - const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; - expect(ws.url).toBe("ws://localhost:8000/api/v1/projects/proj-1/lint/ws"); - }); - - it("transitions to connecting then connected on open", () => { - const { result } = renderHook(() => - useCollaborationStatus({ projectId: "proj-1", enabled: true }), - ); - - act(() => { - vi.advanceTimersByTime(100); - }); - - // Should be connecting after timer - expect(result.current.status).toBe("connecting"); - - const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; - act(() => { - ws.onopen?.(); - }); - - expect(result.current.status).toBe("connected"); - expect(result.current.isConnected).toBe(true); - }); - - it("transitions to disconnected on close", () => { - const { result } = renderHook(() => - useCollaborationStatus({ projectId: "proj-1", enabled: true }), - ); - - act(() => { - vi.advanceTimersByTime(100); - }); - - const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; - act(() => { - ws.onopen?.(); - }); - expect(result.current.status).toBe("connected"); - - // Simulate a close (not initiated by disconnect()) - act(() => { - ws.onclose?.(); - }); - - expect(result.current.status).toBe("disconnected"); - }); - - it("cleans up WebSocket on unmount", () => { - const { unmount } = renderHook(() => - useCollaborationStatus({ projectId: "proj-1", enabled: true }), - ); - - act(() => { - vi.advanceTimersByTime(100); - }); - - const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; - act(() => { - ws.onopen?.(); - }); - - unmount(); - - expect(ws.close).toHaveBeenCalled(); - }); - - it("does not create WebSocket when projectId is empty", () => { - renderHook(() => - useCollaborationStatus({ projectId: "", enabled: true }), - ); - - act(() => { - vi.advanceTimersByTime(200); - }); - - // No WebSocket should have been created (or only from cleanup) - const connectedInstances = MockWebSocket.instances.filter( - (ws) => ws.url.includes("/lint/ws"), - ); - expect(connectedInstances).toHaveLength(0); + expect(result.current.purpose).toBe("Real-time collaboration (coming soon)"); }); }); diff --git a/__tests__/lib/hooks/useProjectViewer.test.ts b/__tests__/lib/hooks/useProjectViewer.test.ts index 5d60c500..37a3286b 100644 --- a/__tests__/lib/hooks/useProjectViewer.test.ts +++ b/__tests__/lib/hooks/useProjectViewer.test.ts @@ -414,26 +414,6 @@ describe("useProjectViewer", () => { expect(result.current.treeError).toBeNull(); }); - it("exposes collaboration status", () => { - mockUseProject.mockReturnValue({ - project: makeProject(), - isLoading: false, - error: null, - errorKind: null, - }); - - const { result } = renderHook(() => - useProjectViewer({ - projectId: "p1", - accessToken: "tok", - sessionStatus: "authenticated", - activeBranch: "main", - }) - ); - - expect(result.current.connectionStatus).toBe("disconnected"); - }); - it("exposes source state with defaults", () => { mockUseProject.mockReturnValue({ project: makeProject(), diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index 186e3773..d713884d 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -42,6 +42,8 @@ import { useSuggestionBeacon } from "@/lib/hooks/useSuggestionBeacon"; import { DeleteImpactAnalysis } from "@/components/editor/DeleteImpactAnalysis"; import { RemoteSyncIndicator } from "@/components/editor/RemoteSyncIndicator"; import { ShareButton } from "@/components/editor/ShareButton"; +import { useCollaborationStatus } from "@/lib/hooks/useCollaborationStatus"; +import type { ConnectionState } from "@/components/ui/ConnectionStatus"; import type { OntologySourceEditorRef } from "@/components/editor/OntologySourceEditor"; @@ -90,7 +92,6 @@ export default function EditorPage() { accessToken: session?.accessToken, sessionStatus: status, activeBranch, - enableWebSocket: true, }); const { @@ -107,13 +108,22 @@ export default function EditorPage() { selectedNodeFallback, sourceContent, setSourceContent, isLoadingSource, sourceError, isPreloading, loadSourceContent, sourceIriIndex, setSourceIriIndex, - connectionStatus, wsEndpoint, wsPurpose, resetSourceState, } = viewer; + const collaboration = useCollaborationStatus({ projectId }); + const [lintWsStatus, setLintWsStatus] = useState("disconnected"); + // UI state (editor-only) const [showHistory, setShowHistory] = useState(false); const [showHealthCheck, setShowHealthCheck] = useState(false); + + // Reset lint WS status when the health check panel is hidden or mode switches away from developer + useEffect(() => { + if (!showHealthCheck || editorMode !== "developer") { + setLintWsStatus("disconnected"); + } + }, [showHealthCheck, editorMode]); const sourceEditorRef = useRef(null); const entityNavigationRef = useRef<((iri: string, type?: string) => void) | null>(null); @@ -953,15 +963,17 @@ export default function EditorPage() { {/* WebSocket Connection Status */}
- + {showHealthCheck && ( + + )}
{/* Branch Selector */} @@ -1065,6 +1077,7 @@ export default function EditorPage() { accessToken={session?.accessToken} activeBranch={activeBranch} canEdit={!!canEdit} + canManage={!!canManage} entityNavigationRef={entityNavigationRef} canSuggest={!!canSuggest} isSuggestionMode={isSuggestionMode} @@ -1100,6 +1113,9 @@ export default function EditorPage() { selectedNodeFallback={selectedNodeFallback} onUpdateClass={isSuggestionMode ? handleSuggestClassUpdate : handleUpdateClass} detailRefreshKey={detailRefreshKey} + showHealthCheck={showHealthCheck} + onCloseHealthCheck={() => setShowHealthCheck(false)} + onLintWsStatusChange={setLintWsStatus} onUpdateProperty={isSuggestionMode ? handleSuggestPropertyUpdate : handleUpdateProperty} onUpdateIndividual={isSuggestionMode ? handleSuggestIndividualUpdate : handleUpdateIndividual} onReparentClass={handleReparentClass} @@ -1147,8 +1163,8 @@ export default function EditorPage() { )} - {/* Right Panel - Health Check (available in both modes) */} - {showHealthCheck && ( + {/* Right Panel - Health Check (standard mode only; developer mode uses DeveloperEditorLayout's panel) */} + {showHealthCheck && editorMode !== "developer" && (
setShowHealthCheck(false)} + onWsStatusChange={setLintWsStatus} onNavigateToClass={(iri, subjectType) => { if (entityNavigationRef.current) { entityNavigationRef.current(iri, subjectType); diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index 45dd58a2..ae9b9e8c 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -20,6 +20,7 @@ import { useSelectionStore } from "@/lib/stores/selectionStore"; import { useToast } from "@/lib/context/ToastContext"; import { useProject, derivePermissions } from "@/lib/hooks/useProject"; import type { OntologySourceEditorRef } from "@/components/editor/OntologySourceEditor"; +import { ConnectionStatus } from "@/components/ui/ConnectionStatus"; export default function ProjectViewerPage() { const { data: session, status } = useSession(); @@ -274,6 +275,15 @@ function ViewerContent({
+ {/* WebSocket Connection Status */} +
+ +
+ {/* Share */} void; /** Whether the current user can trigger a lint run (requires admin/manager role) */ canRunLint?: boolean; + /** Called whenever the lint WebSocket connection state changes */ + onWsStatusChange?: (status: ConnectionState) => void; } type HealthTab = "lint" | "consistency" | "duplicates"; @@ -67,6 +70,7 @@ export function HealthCheckPanel({ isOpen, onClose, onNavigateToClass, + onWsStatusChange, }: HealthCheckPanelProps) { const [activeTab, setActiveTab] = useState("lint"); const [summary, setSummary] = useState(null); @@ -191,12 +195,18 @@ export function HealthCheckPanel({ // WebSocket for real-time updates useEffect(() => { - if (!isOpen || !accessToken) return; + if (!isOpen) { + onWsStatusChange?.("disconnected"); + return; + } + if (!accessToken) return; // Track if this effect is still active (handles React Strict Mode double-invoke) let isActive = true; let ws: WebSocket | null = null; + onWsStatusChange?.("connecting"); + const handleMessage = (message: LintWebSocketMessage) => { if (!isActive) return; if (message.type === "lint_started") { @@ -209,24 +219,26 @@ export function HealthCheckPanel({ // Small delay to avoid spurious connections during Strict Mode remounts const timeoutId = setTimeout(() => { - if (isActive) { - ws = createLintWebSocket( - projectId, - handleMessage, - () => { - if (isActive) { - setIsRunning(false); - setError("Lint WebSocket connection failed"); - } - }, - (event) => { - if (isActive && event.code !== 1000) { - setIsRunning(false); - } - }, - accessToken - ); - } + if (!isActive) return; + ws = createLintWebSocket( + projectId, + handleMessage, + () => { + if (isActive) { + setIsRunning(false); + setError("Lint WebSocket connection failed"); + } + }, + (event) => { + if (isActive && event.code !== 1000) { + setIsRunning(false); + } + }, + accessToken + ); + ws.addEventListener("open", () => { if (isActive) onWsStatusChange?.("connected"); }); + ws.addEventListener("close", () => { if (isActive) onWsStatusChange?.("disconnected"); }); + ws.addEventListener("error", () => { if (isActive) onWsStatusChange?.("disconnected"); }); }, 100); return () => { @@ -235,8 +247,9 @@ export function HealthCheckPanel({ if (ws) { ws.close(); } + onWsStatusChange?.("disconnected"); }; - }, [isOpen, projectId, accessToken]); + }, [isOpen, projectId, accessToken, onWsStatusChange]); const handleClearResults = async () => { if (!accessToken) return; diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index d6638d7b..7107f515 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -30,6 +30,7 @@ import { useSelectionStore } from "@/lib/stores/selectionStore"; import { getLocalName } from "@/lib/utils"; import { extractTreeLabelMap } from "@/lib/graph/buildGraphData"; import { useAnnounce } from "@/components/ui/ScreenReaderAnnouncer"; +import { HealthCheckPanel } from "@/components/editor/HealthCheckPanel"; const OntologySourceEditor = dynamic( () => import("@/components/editor/OntologySourceEditor").then((mod) => mod.OntologySourceEditor), @@ -62,6 +63,7 @@ export interface DeveloperEditorLayoutProps { accessToken?: string; activeBranch?: string; canEdit: boolean; + canManage?: boolean; canSuggest?: boolean; isSuggestionMode?: boolean; // Tree state (from useOntologyTree) @@ -106,6 +108,11 @@ export interface DeveloperEditorLayoutProps { onUpdateClass?: (classIri: string, data: ClassUpdatePayload) => Promise; detailRefreshKey?: number; + // Side panels + showHealthCheck?: boolean; + onCloseHealthCheck?: () => void; + onLintWsStatusChange?: (status: import("@/components/ui/ConnectionStatus").ConnectionState) => void; + // Property & Individual editing onUpdateProperty?: (propertyIri: string, data: TurtlePropertyUpdateData) => Promise; onUpdateIndividual?: (individualIri: string, data: TurtleIndividualUpdateData) => Promise; @@ -125,6 +132,7 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { accessToken, activeBranch, canEdit, + canManage = false, // eslint-disable-next-line @typescript-eslint/no-unused-vars canSuggest = false, isSuggestionMode = false, @@ -160,6 +168,9 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { selectedNodeFallback, onUpdateClass, detailRefreshKey, + showHealthCheck = false, + onCloseHealthCheck, + onLintWsStatusChange, onUpdateProperty, onUpdateIndividual, onReparentClass, @@ -597,6 +608,21 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { )}
+ {/* Right Panel - Health Check */} + {showHealthCheck && ( +
+ {})} + onNavigateToClass={(iri) => navigateToNode(iri)} + canRunLint={canManage} + onWsStatusChange={onLintWsStatusChange} + /> +
+ )} ) : ( /* Source View */ diff --git a/lib/hooks/useCollaborationStatus.ts b/lib/hooks/useCollaborationStatus.ts index 3f69aa49..4d37b628 100644 --- a/lib/hooks/useCollaborationStatus.ts +++ b/lib/hooks/useCollaborationStatus.ts @@ -1,158 +1,22 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; import type { ConnectionState } from "@/components/ui/ConnectionStatus"; -interface UseCollaborationStatusOptions { - projectId: string; - enabled?: boolean; - token?: string; -} - /** - * Hook to track WebSocket connection status for the lint endpoint. - * Maintains a persistent connection and reports status changes. + * Hook for real-time collaboration presence status. + * Currently disabled — the /collab/ws endpoint does not exist yet. + * When the endpoint is available, this hook should establish a WebSocket + * connection and return live presence/connection state. */ -export function useCollaborationStatus({ - projectId, - enabled = true, - token, -}: UseCollaborationStatusOptions) { - const [status, setStatus] = useState("disconnected"); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const reconnectAttemptsRef = useRef(0); - const maxReconnectAttempts = 5; - const isClosingRef = useRef(false); - const connectRef = useRef<() => void>(() => {}); - - const getWsUrl = useCallback(() => { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; - const wsUrl = apiUrl.replace(/^http/, "ws"); - const params = token ? `?token=${encodeURIComponent(token)}` : ""; - return `${wsUrl}/api/v1/projects/${projectId}/lint/ws${params}`; - }, [projectId, token]); - - const connect = useCallback(() => { - if (!enabled || !projectId || isClosingRef.current) return; - - // Clean up existing connection - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - - setStatus("connecting"); - - try { - const ws = new WebSocket(getWsUrl()); - wsRef.current = ws; - - ws.onopen = () => { - reconnectAttemptsRef.current = 0; - setStatus("connected"); - }; - - ws.onclose = () => { - if (isClosingRef.current) { - setStatus("disconnected"); - return; - } - - setStatus("disconnected"); - - // Attempt reconnection with exponential backoff - if (reconnectAttemptsRef.current < maxReconnectAttempts) { - const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); - reconnectAttemptsRef.current++; - - reconnectTimeoutRef.current = setTimeout(() => { - setStatus("connecting"); - connectRef.current(); - }, delay); - } - }; - - ws.onerror = () => { - // Error will trigger onclose, so we don't need to do much here - }; - - // Handle messages from the lint WebSocket - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - // Lint WebSocket sends messages about lint run status - // We don't need to do anything special with them here, - // but we could emit events if needed in the future - console.log("[WebSocket] Lint update:", data.type); - } catch { - // Ignore parse errors - } - }; - } catch { - setStatus("disconnected"); - } - }, [enabled, projectId, getWsUrl]); - - // Keep the ref in sync so the onclose callback always calls the latest version - useEffect(() => { - connectRef.current = connect; - }, [connect]); - - const disconnect = useCallback(() => { - isClosingRef.current = true; - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - - setStatus("disconnected"); - }, []); - - // Connect when enabled and we have a project ID - useEffect(() => { - if (enabled && projectId) { - isClosingRef.current = false; - // Small delay to avoid double-connect in React Strict Mode - const timeout = setTimeout(connect, 100); - return () => { - clearTimeout(timeout); - disconnect(); - }; - } else { - disconnect(); - } - }, [enabled, projectId, connect, disconnect]); - - // Cleanup on unmount - useEffect(() => { - return () => { - isClosingRef.current = true; - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (wsRef.current) { - wsRef.current.close(); - } - }; - }, []); - - // Get the endpoint path for display (without the host) - const endpointPath = `/api/v1/projects/${projectId}/lint/ws`; +export function useCollaborationStatus(_options: { projectId: string; enabled?: boolean }) { + const status: ConnectionState = "disabled"; return { status, - isConnected: status === "connected", - reconnect: connect, - /** The WebSocket endpoint path */ - endpoint: endpointPath, - /** Description of what this WebSocket connection is used for */ - purpose: "Real-time lint status updates", + isConnected: false, + /** The collaboration WebSocket endpoint path (not yet available) */ + endpoint: "/api/v1/collab/ws", + /** Description of what this connection is for */ + purpose: "Real-time collaboration (coming soon)", }; } diff --git a/lib/hooks/useProjectViewer.ts b/lib/hooks/useProjectViewer.ts index e39592cb..f903b452 100644 --- a/lib/hooks/useProjectViewer.ts +++ b/lib/hooks/useProjectViewer.ts @@ -1,6 +1,5 @@ import { useState, useCallback, useRef, useMemo, useEffect } from "react"; import { useOntologyTree } from "@/lib/hooks/useOntologyTree"; -import { useCollaborationStatus } from "@/lib/hooks/useCollaborationStatus"; import { useProject, derivePermissions } from "@/lib/hooks/useProject"; import { useOpenPRCount } from "@/lib/hooks/useOpenPRCount"; import { useLintSummary } from "@/lib/hooks/useLintSummary"; @@ -15,8 +14,6 @@ export interface UseProjectViewerOptions { accessToken?: string; sessionStatus: "loading" | "authenticated" | "unauthenticated"; activeBranch?: string; - /** Enable WebSocket connections (lint status, collaboration). Defaults to false. */ - enableWebSocket?: boolean; } export function useProjectViewer({ @@ -24,7 +21,6 @@ export function useProjectViewer({ accessToken, sessionStatus, activeBranch, - enableWebSocket = false, }: UseProjectViewerOptions) { // Project data from shared React Query cache const { @@ -70,13 +66,6 @@ export function useProjectViewer({ branchKey: activeBranch, }); - // WebSocket connection status (editor only) - const collaboration = useCollaborationStatus({ - projectId, - enabled: enableWebSocket && !!projectId && !!accessToken && sessionStatus === "authenticated", - token: accessToken, - }); - // Derive fallback data for the selected node from the tree const { selectedIri, nodes } = tree; const selectedNodeFallback = useMemo((): TreeNodeFallback | null => { @@ -295,10 +284,5 @@ export function useProjectViewer({ setSourceIriIndex, isIndexing, resetSourceState, - - // Collaboration - connectionStatus: collaboration.status, - wsEndpoint: collaboration.endpoint, - wsPurpose: collaboration.purpose, }; }