1- import { FF_DEV_1752 , FF_DEV_2186 , FF_DEV_2887 , FF_DEV_3034 , FF_LSDV_4620_3_ML , isFF } from "../utils/feature-flags" ;
1+ import { Button } from "@humansignal/ui" ;
2+ import {
3+ FF_DEV_1752 ,
4+ FF_DEV_2186 ,
5+ FF_DEV_2887 ,
6+ FF_DEV_3034 ,
7+ FF_LSDV_4620_3_ML ,
8+ FF_FIT_1304_STRICT_OVERLAP ,
9+ isFF ,
10+ } from "../utils/feature-flags" ;
211import { isDefined } from "../utils/utils" ;
312import { Modal } from "../components/Common/Modal/Modal" ;
413import { CommentsSdk } from "./comments-sdk" ;
@@ -32,13 +41,24 @@ const resolveLabelStudio = () => {
3241} ;
3342
3443// Returns true to suppress (swallow) the error, false to bubble to global handler.
35- // We allow 403 PAUSED to bubble so the app-level ApiProvider can show the paused modal
36- const errorHandlerAllowPaused = ( result ) => {
44+ // We allow certain errors to bubble so the app-level ApiProvider can show modals:
45+ // - 403 PAUSED: User is paused in the project
46+ // - 400 OVERLAP_REACHED: Annotation overlap limit has been reached (only when feature flag is enabled)
47+ const errorHandlerAllowSpecialErrors = ( result ) => {
3748 const isPaused =
3849 result ?. status === 403 &&
3950 typeof result ?. response === "object" &&
4051 result ?. response ?. display_context ?. reason === "PAUSED" ;
41- return ! isPaused ;
52+
53+ // Only handle OVERLAP_REACHED when feature flag is enabled
54+ const isOverlapReached =
55+ isFF ( FF_FIT_1304_STRICT_OVERLAP ) &&
56+ result ?. status === 400 &&
57+ typeof result ?. response === "object" &&
58+ result ?. response ?. display_context ?. reason === "OVERLAP_REACHED" ;
59+
60+ // Return false to allow these errors to bubble up to the global handler
61+ return ! ( isPaused || isOverlapReached ) ;
4262} ;
4363
4464// Support portal URL constants used to construct error reporting links
@@ -47,6 +67,10 @@ const errorHandlerAllowPaused = (result) => {
4767export const SUPPORT_URL = "https://support.humansignal.com/hc/en-us/requests/new" ;
4868export const SUPPORT_URL_REQUEST_ID_PARAM = "tf_37934448633869" ; // request_id field ID in ZD
4969
70+ // Toast ID for overlap reached message - used to dismiss this specific toast
71+ // without affecting other toasts like "Annotation Saved"
72+ const OVERLAP_TOAST_ID = "overlap-reached-toast" ;
73+
5074export class LSFWrapper {
5175 /** @type {HTMLElement } */
5276 root = null ;
@@ -106,6 +130,16 @@ export class LSFWrapper {
106130 this . interfacesModifier = interfacesModifier ;
107131 this . isInteractivePreannotations = isInteractivePreannotations ?? false ;
108132
133+ // Listen for overlap error modal events (only when feature flag is enabled)
134+ if ( isFF ( FF_FIT_1304_STRICT_OVERLAP ) ) {
135+ this . handleOverlapNextTask = ( ) => this . loadTask ( ) ;
136+ this . handleOverlapCloseTask = ( ) => this . closeTask ( ) ;
137+ this . handleOverlapExitStream = ( ) => this . exitStream ( ) ;
138+ window . addEventListener ( "overlap-error-next-task" , this . handleOverlapNextTask ) ;
139+ window . addEventListener ( "overlap-error-close-task" , this . handleOverlapCloseTask ) ;
140+ window . addEventListener ( "overlap-error-exit-stream" , this . handleOverlapExitStream ) ;
141+ }
142+
109143 let interfaces = [ ...DEFAULT_INTERFACES ] ;
110144
111145 if ( this . project . enable_empty_annotation === false ) {
@@ -343,6 +377,10 @@ export class LSFWrapper {
343377 setLSFTask ( task , annotationID , fromHistory , selectPrediction = false ) {
344378 if ( ! this . lsf ) return ;
345379
380+ if ( isFF ( FF_FIT_1304_STRICT_OVERLAP ) ) {
381+ this . dismissOverlapToast ( ) ;
382+ }
383+
346384 const hasChangedTasks = this . lsf ?. task ?. id !== task ?. id && task ?. id ;
347385
348386 this . setLoading ( true , hasChangedTasks ) ;
@@ -383,10 +421,74 @@ export class LSFWrapper {
383421 // undefined or true for backward compatibility
384422 this . lsf . toggleInterface ( "postpone" , this . task . allow_postpone !== false ) ;
385423 this . lsf . toggleInterface ( "topbar:task-counter" , true ) ;
424+
425+ if ( isFF ( FF_FIT_1304_STRICT_OVERLAP ) ) {
426+ // Handle strict task overlap - disable submission controls when overlap is reached
427+ // Only process when feature flag is enabled
428+ const overlapReached = this . task . overlap_reached === true ;
429+ this . overlapReached = overlapReached ;
430+ this . overlapReachedMessage =
431+ this . task . overlap_reached_message ||
432+ "Annotation overlap has been reached for this task. Your draft is preserved but cannot be submitted." ;
433+
434+ // Set overlap state on LSF store - this will disable buttons with tooltips
435+ this . lsf . setFlags ( {
436+ overlapReached,
437+ overlapReachedMessage : this . overlapReachedMessage ,
438+ } ) ;
439+ } else {
440+ this . overlapReached = false ;
441+ this . overlapReachedMessage = "" ;
442+ }
443+
386444 this . lsf . assignTask ( task ) ;
387445 this . lsf . initializeStore ( lsfTask ) ;
388446 this . setAnnotation ( annotationID , fromHistory || isRejectedQueue , selectPrediction ) ;
389447 this . setLoading ( false ) ;
448+
449+ if ( isFF ( FF_FIT_1304_STRICT_OVERLAP ) && this . overlapReached ) {
450+ // Show informational message if overlap is reached (only when feature flag is enabled)
451+ this . showOverlapReachedMessage ( ) ;
452+ }
453+ }
454+
455+ /**
456+ * Show informational message when overlap is reached
457+ * @private
458+ */
459+ showOverlapReachedMessage ( ) {
460+ // Use info toast to communicate the overlap status
461+ // This is informational, not an error, so we use a neutral tone
462+ // Use a specific ID so we can dismiss this toast without affecting others
463+ this . datamanager . invoke ( "toast" , {
464+ id : OVERLAP_TOAST_ID ,
465+ message : (
466+ < div className = "flex items-center justify-between" >
467+ < span > { this . overlapReachedMessage } </ span >
468+ < Button
469+ onClick = { ( ) => {
470+ this . datamanager . invoke ( "toast:dismiss" , { id : OVERLAP_TOAST_ID } ) ;
471+ this . handleOverlapNextTask ( ) ;
472+ } }
473+ className = "ml-4"
474+ size = "small"
475+ look = "outlined"
476+ >
477+ Next Task
478+ </ Button >
479+ </ div >
480+ ) ,
481+ type : "info" ,
482+ duration : - 1 ,
483+ } ) ;
484+ }
485+
486+ /**
487+ * Dismiss the overlap reached toast if it's showing
488+ * @private
489+ */
490+ dismissOverlapToast ( ) {
491+ this . datamanager . invoke ( "toast:dismiss" , { id : OVERLAP_TOAST_ID } ) ;
390492 }
391493
392494 /** @private */
@@ -597,6 +699,28 @@ export class LSFWrapper {
597699 if ( status === 200 || status === 201 ) {
598700 this . datamanager . invoke ( "toast" , { message : successMessage , type : "info" } ) ;
599701 } else if ( status !== undefined ) {
702+ // Skip toast for errors that are handled by global modal handlers via display_context
703+ // These errors bubble up to ApiProvider which shows appropriate modals
704+ // Note: display_context is in result.response for API error responses
705+ const displayReason = result ?. response ?. display_context ?. reason ;
706+ const isPausedError = displayReason === "PAUSED" ;
707+ const isOverlapError = isFF ( FF_FIT_1304_STRICT_OVERLAP ) && displayReason === "OVERLAP_REACHED" ;
708+ if ( isPausedError || isOverlapError ) {
709+ // Also update local state for overlap reached (only when feature flag is enabled)
710+ if ( isOverlapError ) {
711+ this . overlapReached = true ;
712+ this . overlapReachedMessage =
713+ result ?. response ?. detail ||
714+ "Annotation overlap has been reached for this task. Your draft is preserved but cannot be submitted." ;
715+ // Set overlap state on LSF store - this will disable buttons with tooltips
716+ this . lsf . setFlags ( {
717+ overlapReached : true ,
718+ overlapReachedMessage : this . overlapReachedMessage ,
719+ } ) ;
720+ }
721+ return ;
722+ }
723+
600724 const requestId = result ?. $meta ?. headers ?. get ( "x-ls-request-id" ) ;
601725 const supportUrl = requestId ? `${ SUPPORT_URL } ?${ SUPPORT_URL_REQUEST_ID_PARAM } =${ requestId } ` : SUPPORT_URL ;
602726
@@ -623,6 +747,12 @@ export class LSFWrapper {
623747
624748 /** @private */
625749 onSubmitAnnotation = async ( ) => {
750+ // Prevent submission if overlap is reached (only when feature flag is enabled)
751+ if ( isFF ( FF_FIT_1304_STRICT_OVERLAP ) && this . overlapReached ) {
752+ this . showOverlapReachedMessage ( ) ;
753+ return ;
754+ }
755+
626756 const exitStream = this . shouldExitStream ( ) ;
627757 const loadNext = exitStream ? false : this . shouldLoadNext ( ) ;
628758 const result = await this . submitCurrentAnnotation (
@@ -633,7 +763,7 @@ export class LSFWrapper {
633763 { taskID } ,
634764 { body } ,
635765 // errors are displayed by "toast" event - we don't want to show blocking modal
636- { errorHandler : errorHandlerAllowPaused } ,
766+ { errorHandler : errorHandlerAllowSpecialErrors } ,
637767 ) ;
638768 } ,
639769 false ,
@@ -667,7 +797,7 @@ export class LSFWrapper {
667797 body : serializedAnnotation ,
668798 } ,
669799 // errors are displayed by "toast" event - we don't want to show blocking modal
670- { errorHandler : errorHandlerAllowPaused } ,
800+ { errorHandler : errorHandlerAllowSpecialErrors } ,
671801 ) ;
672802 } ) ;
673803 const status = result ?. $meta ?. status ;
@@ -804,6 +934,12 @@ export class LSFWrapper {
804934 } ;
805935
806936 onSkipTask = async ( _ , { comment } = { } ) => {
937+ // Prevent skipping if overlap is reached (only when feature flag is enabled)
938+ if ( isFF ( FF_FIT_1304_STRICT_OVERLAP ) && this . overlapReached ) {
939+ this . showOverlapReachedMessage ( ) ;
940+ return ;
941+ }
942+
807943 // Manager roles that can force-skip unskippable tasks (OW=Owner, AD=Admin, MA=Manager)
808944 const MANAGER_ROLES = [ "OW" , "AD" , "MA" ] ;
809945 const task = this . task ;
@@ -814,7 +950,9 @@ export class LSFWrapper {
814950 const canSkip = ! skipDisabled || hasForceSkipPermission ;
815951 if ( ! canSkip ) {
816952 console . warn ( "Task cannot be skipped: allow_skip is false and user lacks manager role" ) ;
817- this . showOperationToast ( 400 , null , "This task cannot be skipped" , { error : "Task cannot be skipped" } ) ;
953+ this . showOperationToast ( 400 , null , "This task cannot be skipped" , {
954+ error : "Task cannot be skipped" ,
955+ } ) ;
818956 return ;
819957 }
820958 const result = await this . submitCurrentAnnotation (
@@ -832,7 +970,7 @@ export class LSFWrapper {
832970 id === undefined ? "submitAnnotation" : "updateAnnotation" ,
833971 params ,
834972 options ,
835- { errorHandler : errorHandlerAllowPaused } ,
973+ { errorHandler : errorHandlerAllowSpecialErrors } ,
836974 ) ;
837975 } ,
838976 true ,
@@ -1082,10 +1220,28 @@ export class LSFWrapper {
10821220 }
10831221
10841222 destroy ( ) {
1223+ // Clean up overlap error event listeners and dismiss toast (only when feature flag is enabled)
1224+ if ( isFF ( FF_FIT_1304_STRICT_OVERLAP ) ) {
1225+ window . removeEventListener ( "overlap-error-next-task" , this . handleOverlapNextTask ) ;
1226+ window . removeEventListener ( "overlap-error-close-task" , this . handleOverlapCloseTask ) ;
1227+ window . removeEventListener ( "overlap-error-exit-stream" , this . handleOverlapExitStream ) ;
1228+ // Dismiss the overlap toast if it's showing - this ensures the toast doesn't
1229+ // persist after leaving the labeling interface
1230+ this . dismissOverlapToast ( ) ;
1231+ }
1232+
10851233 this . lsfInstance ?. destroy ?. ( ) ;
10861234 this . lsfInstance = null ;
10871235 }
10881236
1237+ /**
1238+ * Close the current task panel (for DataManager context)
1239+ */
1240+ closeTask ( ) {
1241+ // Invoke the data manager's close task action
1242+ this . datamanager . invoke ( "closeTask" ) ;
1243+ }
1244+
10891245 get taskID ( ) {
10901246 return this . task . id ;
10911247 }
0 commit comments