1- import React , { useCallback , useRef , useState } from 'react' ;
1+ import React , { useCallback , useEffect , useRef , useState } from 'react' ;
22import { useI18n } from '@/infrastructure/i18n' ;
33import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext' ;
44import { notificationService } from '@/shared/notification-system' ;
@@ -39,6 +39,12 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
3939 // Refs for values that must be read inside event handlers without stale closures
4040 const draggedWorkspaceIdRef = useRef < string | null > ( null ) ;
4141 const dropTargetRef = useRef < { workspaceId : string ; position : WorkspaceDragPosition } | null > ( null ) ;
42+ // Drag-state safety nets (window dragend/mousedown + reset timeout) to clear
43+ // stuck is-drag-active state when dragend doesn't fire normally.
44+ const dragSafetyTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
45+ const windowDragEndHandlerRef = useRef < ( ( ) => void ) | null > ( null ) ;
46+ const windowMouseDownHandlerRef = useRef < ( ( ) => void ) | null > ( null ) ;
47+ const documentMouseMoveHandlerRef = useRef < ( ( ) => void ) | null > ( null ) ;
4248
4349 const sectionWorkspaces = variant === 'assistants'
4450 ? assistantWorkspacesList
@@ -64,6 +70,54 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
6470 ? t ( 'nav.workspaces.emptyAssistants' )
6571 : t ( 'nav.workspaces.emptyProjects' ) ;
6672
73+ const removeDragArtifacts = useCallback ( ( ) => {
74+ if ( dragSafetyTimeoutRef . current !== null ) {
75+ clearTimeout ( dragSafetyTimeoutRef . current ) ;
76+ dragSafetyTimeoutRef . current = null ;
77+ }
78+ if ( windowDragEndHandlerRef . current !== null ) {
79+ window . removeEventListener ( 'dragend' , windowDragEndHandlerRef . current ) ;
80+ windowDragEndHandlerRef . current = null ;
81+ }
82+ if ( windowMouseDownHandlerRef . current !== null ) {
83+ window . removeEventListener ( 'mousedown' , windowMouseDownHandlerRef . current ) ;
84+ windowMouseDownHandlerRef . current = null ;
85+ }
86+ if ( documentMouseMoveHandlerRef . current !== null ) {
87+ document . removeEventListener ( 'mousemove' , documentMouseMoveHandlerRef . current ) ;
88+ documentMouseMoveHandlerRef . current = null ;
89+ }
90+ } , [ ] ) ;
91+
92+ const clearDragState = useCallback ( ( ) => {
93+ draggedWorkspaceIdRef . current = null ;
94+ dropTargetRef . current = null ;
95+ setDraggedWorkspaceId ( null ) ;
96+ setDropTarget ( null ) ;
97+ } , [ ] ) ;
98+
99+ const cleanupDrag = useCallback ( ( ) => {
100+ removeDragArtifacts ( ) ;
101+ clearDragState ( ) ;
102+ } , [ removeDragArtifacts , clearDragState ] ) ;
103+
104+ useEffect ( ( ) => {
105+ return ( ) => {
106+ if ( dragSafetyTimeoutRef . current !== null ) {
107+ clearTimeout ( dragSafetyTimeoutRef . current ) ;
108+ }
109+ if ( windowDragEndHandlerRef . current !== null ) {
110+ window . removeEventListener ( 'dragend' , windowDragEndHandlerRef . current ) ;
111+ }
112+ if ( windowMouseDownHandlerRef . current !== null ) {
113+ window . removeEventListener ( 'mousedown' , windowMouseDownHandlerRef . current ) ;
114+ }
115+ if ( documentMouseMoveHandlerRef . current !== null ) {
116+ document . removeEventListener ( 'mousemove' , documentMouseMoveHandlerRef . current ) ;
117+ }
118+ } ;
119+ } , [ ] ) ;
120+
67121 const handleDragStart = useCallback ( ( workspaceId : string ) => ( event : React . DragEvent < HTMLDivElement > ) => {
68122 const payload : WorkspaceDragPayload = { workspaceId, variant } ;
69123 const serializedPayload = JSON . stringify ( payload ) ;
@@ -72,14 +126,44 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
72126 event . dataTransfer . setData ( 'text/plain' , serializedPayload ) ;
73127 draggedWorkspaceIdRef . current = workspaceId ;
74128 setDraggedWorkspaceId ( workspaceId ) ;
75- } , [ variant ] ) ;
129+
130+ // Native drag ghost is used (no setDragImage / no custom preview). The
131+ // drag-state safety nets below clear stuck is-drag-active state if dragend
132+ // doesn't fire normally.
133+ const windowDragEndHandler = ( ) => { cleanupDrag ( ) ; } ;
134+ windowDragEndHandlerRef . current = windowDragEndHandler ;
135+ window . addEventListener ( 'dragend' , windowDragEndHandler , { once : true } ) ;
136+
137+ const windowMouseDownHandler = ( ) => { cleanupDrag ( ) ; } ;
138+ windowMouseDownHandlerRef . current = windowMouseDownHandler ;
139+ window . addEventListener ( 'mousedown' , windowMouseDownHandler , { once : true } ) ;
140+
141+ // mousemove is suppressed during an active HTML5 drag (per spec) and resumes
142+ // when the drag ends — even if dragend is swallowed. So it's a safe instant
143+ // cleanup signal that never interrupts a normal drag.
144+ const documentMouseMoveHandler = ( ) => { cleanupDrag ( ) ; } ;
145+ documentMouseMoveHandlerRef . current = documentMouseMoveHandler ;
146+ document . addEventListener ( 'mousemove' , documentMouseMoveHandler , { once : true } ) ;
147+
148+ dragSafetyTimeoutRef . current = setTimeout ( ( ) => {
149+ cleanupDrag ( ) ;
150+ } , 1500 ) ;
151+ } , [ variant , cleanupDrag ] ) ;
152+
153+ const handleDrag = useCallback ( ( ) => {
154+ // Refresh the stuck-state safety timeout on each drag event so a long drag
155+ // doesn't trip the fallback. (Native ghost is used; no custom preview.)
156+ if ( dragSafetyTimeoutRef . current !== null ) {
157+ clearTimeout ( dragSafetyTimeoutRef . current ) ;
158+ dragSafetyTimeoutRef . current = setTimeout ( ( ) => {
159+ cleanupDrag ( ) ;
160+ } , 1500 ) ;
161+ }
162+ } , [ cleanupDrag ] ) ;
76163
77164 const handleDragEnd = useCallback ( ( ) => {
78- draggedWorkspaceIdRef . current = null ;
79- dropTargetRef . current = null ;
80- setDraggedWorkspaceId ( null ) ;
81- setDropTarget ( null ) ;
82- } , [ ] ) ;
165+ cleanupDrag ( ) ;
166+ } , [ cleanupDrag ] ) ;
83167
84168 const handleDragOver = useCallback ( ( workspaceId : string ) => ( event : React . DragEvent < HTMLDivElement > ) => {
85169 // Browsers block reading dataTransfer data during dragover for security.
@@ -95,6 +179,13 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
95179 event . stopPropagation ( ) ;
96180 event . dataTransfer . dropEffect = 'move' ;
97181
182+ if ( dragSafetyTimeoutRef . current !== null ) {
183+ clearTimeout ( dragSafetyTimeoutRef . current ) ;
184+ dragSafetyTimeoutRef . current = setTimeout ( ( ) => {
185+ cleanupDrag ( ) ;
186+ } , 1500 ) ;
187+ }
188+
98189 // Measure only the workspace card, not the wrapper that includes the drop-line.
99190 const itemEl = event . currentTarget . querySelector < HTMLElement > (
100191 '.bitfun-nav-panel__workspace-item'
@@ -115,7 +206,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
115206 dropTargetRef . current = next ;
116207 return next ;
117208 } ) ;
118- } , [ ] ) ; // Intentionally empty: reads from refs, not closed-over state
209+ } , [ cleanupDrag ] ) ; // cleanupDrag is stable; reads refs for the rest
119210
120211 const handleDragLeave = useCallback ( ( workspaceId : string ) => ( event : React . DragEvent < HTMLDivElement > ) => {
121212 if ( ! event . currentTarget . contains ( event . relatedTarget as Node | null ) ) {
@@ -133,16 +224,23 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
133224 event . dataTransfer . getData ( WORKSPACE_DRAG_MIME_TYPE ) ||
134225 event . dataTransfer . getData ( 'text/plain' ) ;
135226
136- if ( ! payloadText ) return ;
227+ if ( ! payloadText ) {
228+ cleanupDrag ( ) ;
229+ return ;
230+ }
137231
138232 let payload : WorkspaceDragPayload ;
139233 try {
140234 payload = JSON . parse ( payloadText ) as WorkspaceDragPayload ;
141235 } catch {
236+ cleanupDrag ( ) ;
142237 return ;
143238 }
144239
145- if ( ! payload . workspaceId || payload . variant !== variant ) return ;
240+ if ( ! payload . workspaceId || payload . variant !== variant ) {
241+ cleanupDrag ( ) ;
242+ return ;
243+ }
146244
147245 event . preventDefault ( ) ;
148246 event . stopPropagation ( ) ;
@@ -154,6 +252,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
154252 ? dropTargetRef . current . position
155253 : 'after' ;
156254
255+ removeDragArtifacts ( ) ;
157256 draggedWorkspaceIdRef . current = null ;
158257 dropTargetRef . current = null ;
159258 setDropTarget ( null ) ;
@@ -168,7 +267,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
168267 } finally {
169268 setDraggedWorkspaceId ( null ) ;
170269 }
171- } , [ reorderOpenedWorkspacesInSection , t , variant ] ) ;
270+ } , [ reorderOpenedWorkspacesInSection , t , variant , cleanupDrag , removeDragArtifacts ] ) ;
172271
173272 return (
174273 < div data-bf-component = "workspace-list-section" data-bf-part = "root" data-bf-state = { draggedWorkspaceId ? 'dragging' : undefined }
@@ -220,6 +319,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
220319 draggable = { workspaces . length > 1 }
221320 isDragging = { draggedWorkspaceId === workspace . id }
222321 onDragStart = { handleDragStart ( workspace . id ) }
322+ onDrag = { handleDrag }
223323 onDragEnd = { handleDragEnd }
224324 />
225325 { dropTarget ?. workspaceId === workspace . id && dropTarget . position === 'after' ? (
0 commit comments