From 4143523d046189eebe76556f297695b67a0e8c25 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Nov 2025 22:04:16 -0600 Subject: [PATCH 1/7] Fix collaboration cursor disappearing on interaction Resolved issue where collaboration cursors would disappear when clicking between browsers by fixing critical useEffect cleanup bug. Key fixes: - Prevent provider disconnect on dependency changes (only on session change) - Prevent extension recreation with synced event guard - Add provider reinitialization guard - Fix awareness state type (Array not Map) - Generate unique colors per user - Remove mouse position tracking --- .../editor-cache-context.tsx | 101 ++++++++++-------- 1 file changed, 57 insertions(+), 44 deletions(-) diff --git a/src/components/ui/coaching-sessions/editor-cache-context.tsx b/src/components/ui/coaching-sessions/editor-cache-context.tsx index 610ce5f..0ea1f13 100644 --- a/src/components/ui/coaching-sessions/editor-cache-context.tsx +++ b/src/components/ui/coaching-sessions/editor-cache-context.tsx @@ -27,6 +27,7 @@ import { } from "@/types/presence"; import { useCurrentRelationshipRole } from "@/lib/hooks/use-current-relationship-role"; import { logoutCleanupRegistry } from "@/lib/hooks/logout-cleanup-registry"; +import { generateCollaborativeUserColor } from "@/lib/tiptap-utils"; /** * EditorCacheProvider manages TipTap collaboration lifecycle: @@ -86,6 +87,9 @@ export const EditorCacheProvider: React.FC = ({ const yDocRef = useRef(null); const lastSessionIdRef = useRef(null); + // Generate a consistent color for this user session + const userColor = useMemo(() => generateCollaborativeUserColor(), []); + const [cache, setCache] = useState({ yDoc: null, collaborationProvider: null, @@ -126,11 +130,35 @@ export const EditorCacheProvider: React.FC = ({ user: userSession.display_name, }); + // Awareness initialization: establishes user presence in collaborative session + // IMPORTANT: Set awareness BEFORE synced event so CollaborationCaret has user data + const userPresence = createConnectedPresence({ + userId: userSession.id, + name: userSession.display_name, + relationshipRole: userRole, + color: userColor, + }); + + // IMPORTANT: Only set our custom "presence" field + // Let CollaborationCaret manage the "user" field to avoid conflicts + if (provider.awareness) { + provider.awareness.setLocalStateField("presence", userPresence); + } + // Provider event handlers: sync completion enables collaborative editing + // IMPORTANT: Track if we've already created extensions to prevent recreation + let extensionsCreated = false; + provider.on("synced", () => { + if (extensionsCreated) { + return; + } + + extensionsCreated = true; + const collaborativeExtensions = createExtensions(doc, provider, { name: userSession.display_name, - color: "#ffcc00", + color: userColor, }); setCache((prev) => ({ @@ -144,27 +172,12 @@ export const EditorCacheProvider: React.FC = ({ })); }); - // Awareness initialization: establishes user presence in collaborative session - const userPresence = createConnectedPresence({ - userId: userSession.id, - name: userSession.display_name, - relationshipRole: userRole, - color: "#ffcc00", - }); - - provider.setAwarenessField("user", { - name: userSession.display_name, - color: "#ffcc00", - }); - - provider.setAwarenessField("presence", userPresence); - providerRef.current = provider; // Awareness synchronization: tracks all connected users for presence indicators provider.on( "awarenessChange", - ({ states }: { states: Map }) => { + ({ states }: { states: Array<{ clientId: number; [key: string]: any }> }) => { const updatedUsers = new Map(); let currentUserPresence: UserPresence | null = null; @@ -197,44 +210,34 @@ export const EditorCacheProvider: React.FC = ({ userId: userSession.id, name: userSession.display_name, relationshipRole: userRole, - color: "#ffcc00", - }); - provider.setAwarenessField("presence", connectedPresence); - provider.setAwarenessField("user", { - name: userSession.display_name, - color: "#ffcc00", + color: userColor, }); + // Only update our custom "presence" field on reconnect + // CollaborationCaret will handle the "user" field + if (provider.awareness) { + provider.awareness.setLocalStateField("presence", connectedPresence); + } }); provider.on("disconnect", () => { const disconnectedPresence = createDisconnectedPresence(userPresence); - provider.setAwarenessField("presence", disconnectedPresence); - }); - - // Mouse tracking: enables collaborative cursor positioning - const handleMouseMove = (event: MouseEvent) => { - if (providerRef.current) { - providerRef.current.setAwarenessField("user", { - name: userSession.display_name, - color: "#ffcc00", - mouseX: event.clientX, - mouseY: event.clientY, - }); + if (provider.awareness) { + provider.awareness.setLocalStateField("presence", disconnectedPresence); } - }; - - document.addEventListener("mousemove", handleMouseMove); + }); // Graceful disconnect on page unload const handleBeforeUnload = () => { const disconnectedPresence = createDisconnectedPresence(userPresence); - provider.setAwarenessField("presence", disconnectedPresence); + // Use low-level awareness API to match CollaborationCaret + if (provider.awareness) { + provider.awareness.setLocalStateField("presence", disconnectedPresence); + } }; window.addEventListener("beforeunload", handleBeforeUnload); return () => { - document.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("beforeunload", handleBeforeUnload); }; } catch (error) { @@ -274,6 +277,11 @@ export const EditorCacheProvider: React.FC = ({ // Provider initialization or fallback to offline mode if (jwt && !tokenError && userSession) { + // Skip if provider already exists and session hasn't changed + if (providerRef.current && lastSessionIdRef.current === sessionId) { + return; + } + initializeProvider(); } else { // Fallback to offline mode when token is unavailable @@ -294,8 +302,11 @@ export const EditorCacheProvider: React.FC = ({ } return () => { - if (providerRef.current) { + // IMPORTANT: Only disconnect on unmount or session change + // Don't disconnect if dependencies change but provider should stay + if (providerRef.current && lastSessionIdRef.current !== sessionId) { providerRef.current.disconnect(); + providerRef.current = null; } }; }, [ @@ -316,9 +327,11 @@ export const EditorCacheProvider: React.FC = ({ if (provider) { try { - // Clear user presence to signal departure - provider.setAwarenessField("presence", null); - provider.setAwarenessField("user", null); + // Clear our custom presence field on logout + // CollaborationCaret will clean up the "user" field + if (provider.awareness) { + provider.awareness.setLocalStateField("presence", null); + } // Graceful provider shutdown provider.disconnect(); From c74e408987cc9f849e199c89f1ca83816dbf31c8 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Nov 2025 22:06:52 -0600 Subject: [PATCH 2/7] Refactor to use high-level awareness API Use provider.setAwarenessField() instead of provider.awareness.setLocalStateField() for better encapsulation and consistency with the public Hocuspocus API. --- .../editor-cache-context.tsx | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/components/ui/coaching-sessions/editor-cache-context.tsx b/src/components/ui/coaching-sessions/editor-cache-context.tsx index 0ea1f13..f03e304 100644 --- a/src/components/ui/coaching-sessions/editor-cache-context.tsx +++ b/src/components/ui/coaching-sessions/editor-cache-context.tsx @@ -141,9 +141,7 @@ export const EditorCacheProvider: React.FC = ({ // IMPORTANT: Only set our custom "presence" field // Let CollaborationCaret manage the "user" field to avoid conflicts - if (provider.awareness) { - provider.awareness.setLocalStateField("presence", userPresence); - } + provider.setAwarenessField("presence", userPresence); // Provider event handlers: sync completion enables collaborative editing // IMPORTANT: Track if we've already created extensions to prevent recreation @@ -214,25 +212,18 @@ export const EditorCacheProvider: React.FC = ({ }); // Only update our custom "presence" field on reconnect // CollaborationCaret will handle the "user" field - if (provider.awareness) { - provider.awareness.setLocalStateField("presence", connectedPresence); - } + provider.setAwarenessField("presence", connectedPresence); }); provider.on("disconnect", () => { const disconnectedPresence = createDisconnectedPresence(userPresence); - if (provider.awareness) { - provider.awareness.setLocalStateField("presence", disconnectedPresence); - } + provider.setAwarenessField("presence", disconnectedPresence); }); // Graceful disconnect on page unload const handleBeforeUnload = () => { const disconnectedPresence = createDisconnectedPresence(userPresence); - // Use low-level awareness API to match CollaborationCaret - if (provider.awareness) { - provider.awareness.setLocalStateField("presence", disconnectedPresence); - } + provider.setAwarenessField("presence", disconnectedPresence); }; window.addEventListener("beforeunload", handleBeforeUnload); @@ -329,9 +320,7 @@ export const EditorCacheProvider: React.FC = ({ try { // Clear our custom presence field on logout // CollaborationCaret will clean up the "user" field - if (provider.awareness) { - provider.awareness.setLocalStateField("presence", null); - } + provider.setAwarenessField("presence", null); // Graceful provider shutdown provider.disconnect(); From 47fbb075f9c79a3df92ea7aae446af522ce43b27 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Nov 2025 22:12:01 -0600 Subject: [PATCH 3/7] Add comprehensive tests for collaboration cursor fixes Add test coverage for Issue #201 fixes: - Provider lifecycle: verify provider NOT disconnected on re-render with same sessionId - Provider cleanup: verify provider IS disconnected when sessionId changes - Extension creation: verify extensions created only once despite multiple synced events - Awareness API: verify high-level setAwarenessField() is used for presence - Awareness state: verify presence state updates on awarenessChange events - Disconnect handling: verify awareness set to disconnected status on disconnect event --- .../editor-cache-context.test.tsx | 262 +++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) diff --git a/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx b/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx index 4d26830..0404f20 100644 --- a/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx +++ b/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx @@ -169,7 +169,7 @@ describe('EditorCacheProvider', () => { // THE CRITICAL TEST: Logout cleanup it('should destroy TipTap provider when user logs out', async () => { let cacheRef: any = null - + // Start with logged in user render( @@ -198,4 +198,264 @@ describe('EditorCacheProvider', () => { // After reset, the cache should be in loading state (not ready) expect(screen.getByTestId('is-ready')).toHaveTextContent('no') }) + + describe('Provider Lifecycle (Issue #201 Fixes)', () => { + it('should NOT disconnect provider when re-rendering with same sessionId', async () => { + const { TiptapCollabProvider } = await import('@hocuspocus/provider') + + const { rerender } = render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('is-ready')).toHaveTextContent('yes') + }) + + // Get the mock instance and clear previous calls + const mockProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value + vi.clearAllMocks() + + // Re-render the same component (simulates user clicking in editor) + rerender( + + + + ) + + // Wait a bit to ensure no cleanup happens + await new Promise(resolve => setTimeout(resolve, 50)) + + // CRITICAL: Provider should NOT be disconnected + expect(mockProvider?.disconnect).not.toHaveBeenCalled() + + // CRITICAL: Provider should NOT be recreated + expect(TiptapCollabProvider).not.toHaveBeenCalled() + }) + + it('should disconnect old provider when sessionId changes', async () => { + const { TiptapCollabProvider } = await import('@hocuspocus/provider') + + const { rerender } = render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('is-ready')).toHaveTextContent('yes') + }) + + const oldProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value + + // Change session ID + rerender( + + + + ) + + await waitFor(() => { + // Old provider should be disconnected + expect(oldProvider?.disconnect).toHaveBeenCalled() + }) + + // New provider should be created + expect(TiptapCollabProvider).toHaveBeenCalledTimes(2) + }) + }) + + describe('Extension Creation', () => { + it('should create extensions only once even if synced event fires multiple times', async () => { + const { Extensions } = await import('@/components/ui/coaching-sessions/coaching-notes/extensions') + + // Create a mock provider that we can control + const { TiptapCollabProvider } = await import('@hocuspocus/provider') + let syncedCallback: (() => void) | undefined + + vi.mocked(TiptapCollabProvider).mockImplementationOnce(function() { + const provider = { + on: vi.fn((event, callback) => { + if (event === 'synced') { + syncedCallback = callback + } + return provider + }), + off: vi.fn(), + setAwarenessField: vi.fn(), + destroy: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn() + } + return provider as any + }) + + render( + + + + ) + + // Wait for provider initialization + await waitFor(() => { + expect(syncedCallback).toBeDefined() + }) + + // Trigger synced event multiple times + act(() => { + syncedCallback!() + syncedCallback!() + syncedCallback!() + }) + + // Extensions should only be created once + expect(Extensions).toHaveBeenCalledTimes(1) + }) + }) + + describe('Awareness State Management', () => { + it('should use setAwarenessField for setting user presence', async () => { + const { TiptapCollabProvider } = await import('@hocuspocus/provider') + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('is-ready')).toHaveTextContent('yes') + }) + + const mockProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value + + // Should have called setAwarenessField with presence data + expect(mockProvider?.setAwarenessField).toHaveBeenCalledWith( + 'presence', + expect.objectContaining({ + userId: 'user-1', + name: 'Test User', + status: 'connected' + }) + ) + }) + + it('should update presence state when awarenessChange event fires', async () => { + const { TiptapCollabProvider } = await import('@hocuspocus/provider') + let awarenessCallback: ((data: any) => void) | undefined + + vi.mocked(TiptapCollabProvider).mockImplementationOnce(function() { + const provider = { + on: vi.fn((event, callback) => { + if (event === 'awarenessChange') { + awarenessCallback = callback + } + return provider + }), + off: vi.fn(), + setAwarenessField: vi.fn(), + destroy: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn() + } + return provider as any + }) + + let cacheRef: any = null + + render( + + { cacheRef = cache }} /> + + ) + + await waitFor(() => { + expect(awarenessCallback).toBeDefined() + }) + + // Simulate awareness change with user data + act(() => { + awarenessCallback!({ + states: [ + { + clientId: 1, + presence: { + userId: 'user-1', + name: 'Test User', + relationshipRole: 'Coach', + color: '#ff0000', + status: 'connected' + } + }, + { + clientId: 2, + presence: { + userId: 'user-2', + name: 'Other User', + relationshipRole: 'Coachee', + color: '#00ff00', + status: 'connected' + } + } + ] + }) + }) + + // Presence state should be updated + expect(cacheRef?.presenceState.users.size).toBe(2) + expect(cacheRef?.presenceState.users.get('user-1')).toMatchObject({ + userId: 'user-1', + name: 'Test User' + }) + }) + + it('should set disconnected status on disconnect event', async () => { + const { TiptapCollabProvider } = await import('@hocuspocus/provider') + let disconnectCallback: (() => void) | undefined + + vi.mocked(TiptapCollabProvider).mockImplementationOnce(function() { + const provider = { + on: vi.fn((event, callback) => { + if (event === 'disconnect') { + disconnectCallback = callback + } + return provider + }), + off: vi.fn(), + setAwarenessField: vi.fn(), + destroy: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn() + } + return provider as any + }) + + render( + + + + ) + + await waitFor(() => { + expect(disconnectCallback).toBeDefined() + }) + + const mockProvider = vi.mocked(TiptapCollabProvider).mock.results[0]?.value + vi.clearAllMocks() + + // Trigger disconnect event + act(() => { + disconnectCallback!() + }) + + // Should update awareness with disconnected status + expect(mockProvider?.setAwarenessField).toHaveBeenCalledWith( + 'presence', + expect.objectContaining({ + status: 'disconnected' + }) + ) + }) + }) }) \ No newline at end of file From 9ffb66858ae552ceeb4bf7c3896ca4bc1e9f272e Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Nov 2025 22:41:13 -0600 Subject: [PATCH 4/7] Fix presence indicator showing wrong user on network disconnect Root cause: When a user's network goes offline, the disconnect event would try to call setAwarenessField, but this message can't be delivered since the connection is already lost. Additionally, when users disappeared from the awareness states array, they were completely removed from the UI rather than being marked as disconnected. Changes: - Remove setAwarenessField call from disconnect event handler (already offline) - Preserve users who disappear from awareness states as disconnected - Merge previous presence state with current awareness data - Users now show correct disconnected status when they go offline This ensures network disconnect and browser close produce identical behavior: both result in the correct user showing as disconnected (grey indicator). --- .../editor-cache-context.test.tsx | 100 ++++++++++++++++-- .../editor-cache-context.tsx | 50 +++++++-- 2 files changed, 132 insertions(+), 18 deletions(-) diff --git a/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx b/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx index 0404f20..7fee2f7 100644 --- a/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx +++ b/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx @@ -410,7 +410,7 @@ describe('EditorCacheProvider', () => { }) }) - it('should set disconnected status on disconnect event', async () => { + it('should NOT call setAwarenessField on disconnect event (already offline)', async () => { const { TiptapCollabProvider } = await import('@hocuspocus/provider') let disconnectCallback: (() => void) | undefined @@ -449,13 +449,99 @@ describe('EditorCacheProvider', () => { disconnectCallback!() }) - // Should update awareness with disconnected status - expect(mockProvider?.setAwarenessField).toHaveBeenCalledWith( - 'presence', - expect.objectContaining({ - status: 'disconnected' - }) + // Should NOT call setAwarenessField because we're already disconnected + // The awareness protocol will handle removing stale clients via timeout + expect(mockProvider?.setAwarenessField).not.toHaveBeenCalled() + }) + + it('should mark users as disconnected when they disappear from awareness states', async () => { + const { TiptapCollabProvider } = await import('@hocuspocus/provider') + let awarenessCallback: ((data: any) => void) | undefined + + vi.mocked(TiptapCollabProvider).mockImplementationOnce(function() { + const provider = { + on: vi.fn((event, callback) => { + if (event === 'awarenessChange') { + awarenessCallback = callback + } + return provider + }), + off: vi.fn(), + setAwarenessField: vi.fn(), + destroy: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn() + } + return provider as any + }) + + let cacheRef: any = null + + render( + + { cacheRef = cache }} /> + ) + + await waitFor(() => { + expect(awarenessCallback).toBeDefined() + }) + + // First, simulate both users being connected + act(() => { + awarenessCallback!({ + states: [ + { + clientId: 1, + presence: { + userId: 'user-1', + name: 'Test User', + relationshipRole: 'Coach', + color: '#ff0000', + isConnected: true + } + }, + { + clientId: 2, + presence: { + userId: 'user-2', + name: 'Other User', + relationshipRole: 'Coachee', + color: '#00ff00', + isConnected: true + } + } + ] + }) + }) + + // Verify both users are connected + expect(cacheRef?.presenceState.users.size).toBe(2) + expect(cacheRef?.presenceState.users.get('user-2')?.status).toBe('connected') + + // Now simulate user-2 disappearing (network disconnect) + act(() => { + awarenessCallback!({ + states: [ + { + clientId: 1, + presence: { + userId: 'user-1', + name: 'Test User', + relationshipRole: 'Coach', + color: '#ff0000', + isConnected: true + } + } + // user-2 is no longer in the states array + ] + }) + }) + + // User-2 should still be in the map but marked as disconnected + expect(cacheRef?.presenceState.users.size).toBe(2) + expect(cacheRef?.presenceState.users.get('user-2')?.status).toBe('disconnected') + expect(cacheRef?.presenceState.users.get('user-1')?.status).toBe('connected') }) }) }) \ No newline at end of file diff --git a/src/components/ui/coaching-sessions/editor-cache-context.tsx b/src/components/ui/coaching-sessions/editor-cache-context.tsx index f03e304..0be23ae 100644 --- a/src/components/ui/coaching-sessions/editor-cache-context.tsx +++ b/src/components/ui/coaching-sessions/editor-cache-context.tsx @@ -190,15 +190,41 @@ export const EditorCacheProvider: React.FC = ({ } }); - setCache((prev) => ({ - ...prev, - presenceState: { - ...prev.presenceState, - users: updatedUsers, - currentUser: - currentUserPresence || prev.presenceState.currentUser, - }, - })); + // IMPORTANT: Preserve previous users who are no longer in states array + // as disconnected instead of removing them entirely. + // This ensures smooth UX when users go offline (they appear as disconnected + // rather than disappearing completely). + setCache((prev) => { + const mergedUsers = new Map(prev.presenceState.users); + + // Mark users who disappeared from awareness as disconnected + for (const [userId, oldPresence] of prev.presenceState.users) { + if (!updatedUsers.has(userId) && oldPresence.status === 'connected') { + // User was connected but no longer in awareness states - mark as disconnected + mergedUsers.set(userId, { + ...oldPresence, + status: 'disconnected', + isConnected: false, + lastSeen: new Date() + }); + } + } + + // Overlay current awareness data (takes precedence) + for (const [userId, presence] of updatedUsers) { + mergedUsers.set(userId, presence); + } + + return { + ...prev, + presenceState: { + ...prev.presenceState, + users: mergedUsers, + currentUser: + currentUserPresence || prev.presenceState.currentUser, + }, + }; + }); } ); @@ -216,8 +242,10 @@ export const EditorCacheProvider: React.FC = ({ }); provider.on("disconnect", () => { - const disconnectedPresence = createDisconnectedPresence(userPresence); - provider.setAwarenessField("presence", disconnectedPresence); + // NOTE: Don't call setAwarenessField here - we're already disconnected + // so the message won't be delivered to other clients anyway. + // The awareness protocol will automatically remove our state via timeout. + // This event is just for local cleanup/logging if needed. }); // Graceful disconnect on page unload From bfea008a95df3cea0b06d7fbb07f853d13e23021 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Nov 2025 22:46:08 -0600 Subject: [PATCH 5/7] Fix inconsistent collaboration caret heights The caret height was using 1.2em which is relative to the font-size of the current context, causing different heights depending on where the cursor appears in the document. Changes: - Changed height from 1.2em to 1em to match actual text height - Added line-height: inherit to match the line-height of surrounding text - Added vertical-align: baseline for proper alignment - Removed min-height: 16px as it's no longer needed This ensures all collaboration carets have consistent heights that match the line height of the text where they appear. --- src/styles/simple-editor.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/styles/simple-editor.scss b/src/styles/simple-editor.scss index 88d6dbf..ba5418e 100644 --- a/src/styles/simple-editor.scss +++ b/src/styles/simple-editor.scss @@ -395,8 +395,10 @@ position: relative; word-break: normal; animation: blink 1.5s infinite; - height: 1.2em; - min-height: 16px; + /* Use line-height to match the actual text height where cursor appears */ + height: 1em; + line-height: inherit; + vertical-align: baseline; display: inline-block; } From 1e171f264905b79870f130919462a97181748253 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Nov 2025 22:53:56 -0600 Subject: [PATCH 6/7] Add hover transparency effect to collaboration caret name bubbles When hovering over a collaboration caret name bubble with the mouse, it now becomes highly transparent (20% opacity) to improve UX by allowing users to see through the label when it's covering content. Changes: - Changed pointer-events from none to auto to enable hover interactions - Added opacity: 0.2 on hover for the label bubble and tail (80% transparent) - Consolidated duplicate hover effects into single rule - Maintains existing hover transform and shadow effects - Added cursor: pointer to indicate interactivity --- src/styles/simple-editor.scss | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/styles/simple-editor.scss b/src/styles/simple-editor.scss index ba5418e..8d3d5c8 100644 --- a/src/styles/simple-editor.scss +++ b/src/styles/simple-editor.scss @@ -432,11 +432,11 @@ /* Smooth transitions */ transition: all 0.2s ease; transform: translateY(-2px); - + /* CRITICAL: Prevent any animations or opacity changes */ animation: none !important; opacity: 1 !important; - + /* Pointer tail */ &::after { content: ''; @@ -448,13 +448,21 @@ border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--collaboration-user-color, #6366f1); + transition: opacity 0.2s ease; } - - /* Hover effect for better visibility */ + + /* Hover effect - make highly transparent with enhanced shadow */ &:hover { + opacity: 0.2 !important; + cursor: pointer; transform: translateY(-4px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } + + /* Make tail transparent on hover too */ + &:hover::after { + opacity: 0.2; + } /* Dark theme adjustments */ .dark & { @@ -490,7 +498,7 @@ .ProseMirror .collaboration-cursor__label { z-index: 100; - pointer-events: none; + pointer-events: auto; /* Allow hover interactions */ } /* Mobile responsiveness for collaboration carets */ From 1c4d3701d466632d4058539b59b7bdc1d5b69c70 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Fri, 14 Nov 2025 09:25:58 -0600 Subject: [PATCH 7/7] Improve code documentation and test descriptions - Update test description to be self-explanatory without referencing issue number - Add detailed comment explaining awareness state handling for disconnected users --- .../ui/coaching-sessions/editor-cache-context.test.tsx | 2 +- src/components/ui/coaching-sessions/editor-cache-context.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx b/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx index 7fee2f7..2de4168 100644 --- a/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx +++ b/__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx @@ -199,7 +199,7 @@ describe('EditorCacheProvider', () => { expect(screen.getByTestId('is-ready')).toHaveTextContent('no') }) - describe('Provider Lifecycle (Issue #201 Fixes)', () => { + describe('Provider Lifecycle - Preventing Unnecessary Disconnections', () => { it('should NOT disconnect provider when re-rendering with same sessionId', async () => { const { TiptapCollabProvider } = await import('@hocuspocus/provider') diff --git a/src/components/ui/coaching-sessions/editor-cache-context.tsx b/src/components/ui/coaching-sessions/editor-cache-context.tsx index 0be23ae..4bee35f 100644 --- a/src/components/ui/coaching-sessions/editor-cache-context.tsx +++ b/src/components/ui/coaching-sessions/editor-cache-context.tsx @@ -198,6 +198,11 @@ export const EditorCacheProvider: React.FC = ({ const mergedUsers = new Map(prev.presenceState.users); // Mark users who disappeared from awareness as disconnected + // When users "disappear," it means they're no longer in the awareness states array - + // typically due to network disconnect, browser crash, or navigation away from the coaching + // session page. Without this code, disconnected users would instantly vanish from the UI, + // creating an unwanted UX. This preserves them as status: 'disconnected' instead, enabling + // smooth UX transitions (like showing grayed-out presence indicators). for (const [userId, oldPresence] of prev.presenceState.users) { if (!updatedUsers.has(userId) && oldPresence.status === 'connected') { // User was connected but no longer in awareness states - mark as disconnected