11'use client' ;
22
3- import { createContext , use , useState , useCallback , useEffect , useMemo , useRef } from 'react' ;
3+ import { createContext , use , useCallback , useEffect , useMemo , useReducer , useRef } from 'react' ;
44import { useTRPC } from '@/lib/trpc/utils' ;
55import { useQuery , useMutation , useQueries , useQueryClient } from '@tanstack/react-query' ;
66import { toast } from 'sonner' ;
@@ -126,6 +126,83 @@ type SecurityAgentCommand = {
126126 lastErrorRedacted : string | null ;
127127} ;
128128
129+ type SecurityAgentProviderState = {
130+ optimisticStartingAnalysisIds : Set < string > ;
131+ trackedCommandIds : Set < string > ;
132+ processedTerminalCommandIds : Set < string > ;
133+ gitHubError : string | null ;
134+ } ;
135+
136+ type SecurityAgentProviderAction =
137+ | { type : 'track-command' ; commandId : string }
138+ | { type : 'add-optimistic-analysis' ; findingId : string }
139+ | { type : 'remove-optimistic-analysis' ; findingId : string }
140+ | { type : 'settle-commands' ; commands : SecurityAgentCommand [ ] ; gitHubError ?: string }
141+ | { type : 'prune-processed-commands' ; polledCommandIds : Set < string > }
142+ | { type : 'set-github-error' ; error : string | null } ;
143+
144+ function createSecurityAgentProviderState ( ) : SecurityAgentProviderState {
145+ return {
146+ optimisticStartingAnalysisIds : new Set ( ) ,
147+ trackedCommandIds : new Set ( ) ,
148+ processedTerminalCommandIds : new Set ( ) ,
149+ gitHubError : null ,
150+ } ;
151+ }
152+
153+ function securityAgentProviderReducer (
154+ state : SecurityAgentProviderState ,
155+ action : SecurityAgentProviderAction
156+ ) : SecurityAgentProviderState {
157+ switch ( action . type ) {
158+ case 'track-command' :
159+ return {
160+ ...state ,
161+ trackedCommandIds : new Set ( state . trackedCommandIds ) . add ( action . commandId ) ,
162+ } ;
163+ case 'add-optimistic-analysis' :
164+ return {
165+ ...state ,
166+ optimisticStartingAnalysisIds : new Set ( state . optimisticStartingAnalysisIds ) . add (
167+ action . findingId
168+ ) ,
169+ } ;
170+ case 'remove-optimistic-analysis' : {
171+ const optimisticStartingAnalysisIds = new Set ( state . optimisticStartingAnalysisIds ) ;
172+ optimisticStartingAnalysisIds . delete ( action . findingId ) ;
173+ return { ...state , optimisticStartingAnalysisIds } ;
174+ }
175+ case 'settle-commands' : {
176+ const optimisticStartingAnalysisIds = new Set ( state . optimisticStartingAnalysisIds ) ;
177+ const trackedCommandIds = new Set ( state . trackedCommandIds ) ;
178+ const processedTerminalCommandIds = new Set ( state . processedTerminalCommandIds ) ;
179+ for ( const command of action . commands ) {
180+ if ( command . findingId ) optimisticStartingAnalysisIds . delete ( command . findingId ) ;
181+ trackedCommandIds . delete ( command . id ) ;
182+ processedTerminalCommandIds . add ( command . id ) ;
183+ }
184+ return {
185+ optimisticStartingAnalysisIds,
186+ trackedCommandIds,
187+ processedTerminalCommandIds,
188+ gitHubError : action . gitHubError ?? state . gitHubError ,
189+ } ;
190+ }
191+ case 'prune-processed-commands' : {
192+ const processedTerminalCommandIds = new Set (
193+ [ ...state . processedTerminalCommandIds ] . filter ( commandId =>
194+ action . polledCommandIds . has ( commandId )
195+ )
196+ ) ;
197+ return processedTerminalCommandIds . size === state . processedTerminalCommandIds . size
198+ ? state
199+ : { ...state , processedTerminalCommandIds } ;
200+ }
201+ case 'set-github-error' :
202+ return { ...state , gitHubError : action . error } ;
203+ }
204+ }
205+
129206function commandFailureDescription ( command : SecurityAgentCommand ) : string {
130207 switch ( command . resultCode ) {
131208 case 'OWNER_CAP_REACHED' :
@@ -156,19 +233,22 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
156233 const queryClient = useQueryClient ( ) ;
157234 const isOrg = ! ! organizationId ;
158235
159- const [ optimisticStartingAnalysisIds , setOptimisticStartingAnalysisIds ] = useState < Set < string > > (
160- new Set ( )
236+ const [ providerState , dispatchProviderState ] = useReducer (
237+ securityAgentProviderReducer ,
238+ undefined ,
239+ createSecurityAgentProviderState
161240 ) ;
162- const [ trackedCommandIds , setTrackedCommandIds ] = useState < Set < string > > ( new Set ( ) ) ;
163- const [ gitHubError , setGitHubError ] = useState < string | null > ( null ) ;
164241 const toggleEnabledInFlightRef = useRef ( false ) ;
165- const processedTerminalCommandIdsRef = useRef ( new Set < string > ( ) ) ;
166- const recoveredCommandIdsRef = useRef ( new Set < string > ( ) ) ;
167- const commandSuccessCallbacksRef = useRef ( new Map < string , ( ) => void > ( ) ) ;
242+ const commandSuccessCallbacksRef = useRef < Map < string , ( ) => void > > ( null ) ;
168243
169244 const trackCommand = useCallback ( ( commandId : string , onSuccess ?: ( ) => void ) => {
170- if ( onSuccess ) commandSuccessCallbacksRef . current . set ( commandId , onSuccess ) ;
171- setTrackedCommandIds ( previous => new Set ( previous ) . add ( commandId ) ) ;
245+ if ( onSuccess ) {
246+ if ( commandSuccessCallbacksRef . current === null ) {
247+ commandSuccessCallbacksRef . current = new Map ( ) ;
248+ }
249+ commandSuccessCallbacksRef . current . set ( commandId , onSuccess ) ;
250+ }
251+ dispatchProviderState ( { type : 'track-command' , commandId } ) ;
172252 } , [ ] ) ;
173253
174254 const invalidateAcceptedQueueQueries = useCallback ( ( ) => {
@@ -269,12 +349,24 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
269349 query . state . data && query . state . data . length > 0 ? COMMAND_POLL_INTERVAL_MS : false ,
270350 } ) ;
271351
272- useEffect ( ( ) => {
273- for ( const command of activeCommandsData ?? [ ] ) recoveredCommandIdsRef . current . add ( command . id ) ;
274- } , [ activeCommandsData ] ) ;
352+ const commandIdsToPoll = useMemo ( ( ) => {
353+ const commandIds = new Set ( providerState . trackedCommandIds ) ;
354+ for ( const command of activeCommandsData ?? [ ] ) commandIds . add ( command . id ) ;
355+ return commandIds ;
356+ } , [ activeCommandsData , providerState . trackedCommandIds ] ) ;
275357
276- const commandIdsToPoll = new Set ( [ ...trackedCommandIds , ...recoveredCommandIdsRef . current ] ) ;
277- for ( const command of activeCommandsData ?? [ ] ) commandIdsToPoll . add ( command . id ) ;
358+ useEffect ( ( ) => {
359+ if (
360+ [ ...providerState . processedTerminalCommandIds ] . some (
361+ commandId => ! commandIdsToPoll . has ( commandId )
362+ )
363+ ) {
364+ dispatchProviderState ( {
365+ type : 'prune-processed-commands' ,
366+ polledCommandIds : commandIdsToPoll ,
367+ } ) ;
368+ }
369+ } , [ commandIdsToPoll , providerState . processedTerminalCommandIds ] ) ;
278370
279371 const commandStatusQueries = useQueries ( {
280372 queries : [ ...commandIdsToPoll ] . map ( commandId => ( {
@@ -304,32 +396,32 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
304396 command => command . commandType === 'dismiss_finding'
305397 ) ;
306398 const startingAnalysisIds = useMemo ( ( ) => {
307- const ids = new Set ( optimisticStartingAnalysisIds ) ;
399+ const ids = new Set ( providerState . optimisticStartingAnalysisIds ) ;
308400 for ( const command of activeCommands ) {
309401 if ( command . commandType === 'start_analysis' && command . findingId ) {
310402 ids . add ( command . findingId ) ;
311403 }
312404 }
313405 return ids ;
314- } , [ activeCommands , optimisticStartingAnalysisIds ] ) ;
406+ } , [ activeCommands , providerState . optimisticStartingAnalysisIds ] ) ;
315407
316408 useEffect ( ( ) => {
317- for ( const query of commandStatusQueries ) {
409+ const terminalCommands = commandStatusQueries . flatMap ( query => {
318410 const command = query . data ;
319- if ( ! command || command . status === 'accepted' || command . status === 'running' ) continue ;
320- if ( processedTerminalCommandIdsRef . current . has ( command . id ) ) continue ;
321- processedTerminalCommandIdsRef . current . add ( command . id ) ;
322- recoveredCommandIdsRef . current . delete ( command . id ) ;
411+ return command && command . status !== 'accepted' && command . status !== 'running'
412+ ? [ command ]
413+ : [ ] ;
414+ } ) ;
415+ const unprocessedTerminalCommands = terminalCommands . filter (
416+ command => ! providerState . processedTerminalCommandIds . has ( command . id )
417+ ) ;
418+ if ( unprocessedTerminalCommands . length === 0 ) return ;
419+
420+ let terminalGitHubError : string | undefined ;
421+ for ( const command of unprocessedTerminalCommands ) {
323422 invalidateAcceptedQueueQueries ( ) ;
324- if ( command . findingId ) {
325- setOptimisticStartingAnalysisIds ( previous => {
326- const next = new Set ( previous ) ;
327- next . delete ( command . findingId ?? '' ) ;
328- return next ;
329- } ) ;
330- }
331- const successCallback = commandSuccessCallbacksRef . current . get ( command . id ) ;
332- commandSuccessCallbacksRef . current . delete ( command . id ) ;
423+ const successCallback = commandSuccessCallbacksRef . current ?. get ( command . id ) ;
424+ commandSuccessCallbacksRef . current ?. delete ( command . id ) ;
333425 if ( command . status === 'failed' ) {
334426 const title =
335427 command . commandType === 'sync'
@@ -338,7 +430,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
338430 ? 'Failed to dismiss finding'
339431 : 'Failed to start analysis' ;
340432 if ( command . resultCode === 'GITHUB_AUTH_INVALID' ) {
341- setGitHubError ( commandFailureDescription ( command ) ) ;
433+ terminalGitHubError = commandFailureDescription ( command ) ;
342434 }
343435 toast . error ( title , { description : commandFailureDescription ( command ) , duration : 8000 } ) ;
344436 } else {
@@ -349,26 +441,31 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
349441 ) ;
350442 }
351443 }
352- setTrackedCommandIds ( previous => {
353- const next = new Set ( previous ) ;
354- next . delete ( command . id ) ;
355- return next ;
356- } ) ;
357444 }
358- } , [ commandStatusQueries , invalidateAcceptedQueueQueries ] ) ;
445+
446+ dispatchProviderState ( {
447+ type : 'settle-commands' ,
448+ commands : unprocessedTerminalCommands ,
449+ gitHubError : terminalGitHubError ,
450+ } ) ;
451+ } , [
452+ commandStatusQueries ,
453+ providerState . processedTerminalCommandIds ,
454+ invalidateAcceptedQueueQueries ,
455+ ] ) ;
359456
360457 // ---- Mutations (org) ----
361458 const { mutate : orgSyncMutate , isPending : isOrgSyncPending } = useMutation (
362459 trpc . organizations . securityAgent . triggerSync . mutationOptions ( {
363460 onSuccess : data => {
364- setGitHubError ( null ) ;
461+ dispatchProviderState ( { type : 'set-github-error' , error : null } ) ;
365462 toast . success ( 'Sync queued' ) ;
366463 trackCommand ( data . commandId ) ;
367464 } ,
368465 onError : error => {
369466 const message = error instanceof Error ? error . message : String ( error ) ;
370467 if ( isGitHubIntegrationError ( error ) ) {
371- setGitHubError ( message ) ;
468+ dispatchProviderState ( { type : 'set-github-error' , error : message } ) ;
372469 toast . error ( 'GitHub Integration Error' , {
373470 description :
374471 'The GitHub App may have been uninstalled. Please check your integrations.' ,
@@ -440,14 +537,14 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
440537 const { mutate : orgStartAnalysisMutate } = useMutation (
441538 trpc . organizations . securityAgent . startAnalysis . mutationOptions ( {
442539 onSuccess : async data => {
443- setGitHubError ( null ) ;
540+ dispatchProviderState ( { type : 'set-github-error' , error : null } ) ;
444541 toast . success ( manualAnalysisAdmissionCopy . successTitle ) ;
445542 trackCommand ( data . commandId ) ;
446543 } ,
447544 onError : ( error , variables ) => {
448545 const message = error instanceof Error ? error . message : String ( error ) ;
449546 if ( isGitHubIntegrationError ( error ) ) {
450- setGitHubError ( message ) ;
547+ dispatchProviderState ( { type : 'set-github-error' , error : message } ) ;
451548 toast . error ( 'GitHub Integration Error' , {
452549 description :
453550 'The GitHub App may have been uninstalled. Please check your integrations.' ,
@@ -459,10 +556,9 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
459556 } ) ;
460557 }
461558 void queryClient . invalidateQueries ( ) ;
462- setOptimisticStartingAnalysisIds ( prev => {
463- const next = new Set ( prev ) ;
464- next . delete ( variables . findingId ) ;
465- return next ;
559+ dispatchProviderState ( {
560+ type : 'remove-optimistic-analysis' ,
561+ findingId : variables . findingId ,
466562 } ) ;
467563 } ,
468564 } )
@@ -486,14 +582,14 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
486582 const { mutate : personalSyncMutate , isPending : isPersonalSyncPending } = useMutation (
487583 trpc . securityAgent . triggerSync . mutationOptions ( {
488584 onSuccess : data => {
489- setGitHubError ( null ) ;
585+ dispatchProviderState ( { type : 'set-github-error' , error : null } ) ;
490586 toast . success ( 'Sync queued' ) ;
491587 trackCommand ( data . commandId ) ;
492588 } ,
493589 onError : error => {
494590 const message = error instanceof Error ? error . message : String ( error ) ;
495591 if ( isGitHubIntegrationError ( error ) ) {
496- setGitHubError ( message ) ;
592+ dispatchProviderState ( { type : 'set-github-error' , error : message } ) ;
497593 toast . error ( 'GitHub Integration Error' , {
498594 description :
499595 'The GitHub App may have been uninstalled. Please check your integrations.' ,
@@ -565,14 +661,14 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
565661 const { mutate : personalStartAnalysisMutate } = useMutation (
566662 trpc . securityAgent . startAnalysis . mutationOptions ( {
567663 onSuccess : async data => {
568- setGitHubError ( null ) ;
664+ dispatchProviderState ( { type : 'set-github-error' , error : null } ) ;
569665 toast . success ( manualAnalysisAdmissionCopy . successTitle ) ;
570666 trackCommand ( data . commandId ) ;
571667 } ,
572668 onError : ( error , variables ) => {
573669 const message = error instanceof Error ? error . message : String ( error ) ;
574670 if ( isGitHubIntegrationError ( error ) ) {
575- setGitHubError ( message ) ;
671+ dispatchProviderState ( { type : 'set-github-error' , error : message } ) ;
576672 toast . error ( 'GitHub Integration Error' , {
577673 description :
578674 'The GitHub App may have been uninstalled. Please check your integrations.' ,
@@ -584,10 +680,9 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
584680 } ) ;
585681 }
586682 void queryClient . invalidateQueries ( ) ;
587- setOptimisticStartingAnalysisIds ( prev => {
588- const next = new Set ( prev ) ;
589- next . delete ( variables . findingId ) ;
590- return next ;
683+ dispatchProviderState ( {
684+ type : 'remove-optimistic-analysis' ,
685+ findingId : variables . findingId ,
591686 } ) ;
592687 } ,
593688 } )
@@ -721,7 +816,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
721816
722817 const handleStartAnalysis = useCallback (
723818 ( findingId : string , { retrySandboxOnly } : { retrySandboxOnly ?: boolean } = { } ) => {
724- setOptimisticStartingAnalysisIds ( prev => new Set ( prev ) . add ( findingId ) ) ;
819+ dispatchProviderState ( { type : ' add-optimistic-analysis' , findingId } ) ;
725820 if ( isOrg && organizationId ) {
726821 orgStartAnalysisMutate ( { organizationId, findingId, retrySandboxOnly } ) ;
727822 } else {
@@ -802,7 +897,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
802897 isTogglingEnabled : isOrg ? isOrgSetEnabledPending : isPersonalSetEnabledPending ,
803898 isDeletingFindings : isOrg ? isOrgDeleteFindingsPending : isPersonalDeleteFindingsPending ,
804899 startingAnalysisIds,
805- gitHubError,
900+ gitHubError : providerState . gitHubError ,
806901 orphanedRepositories : orphanedReposData ?? [ ] ,
807902 } ) ,
808903 [
@@ -837,7 +932,7 @@ export function SecurityAgentProvider({ organizationId, children }: SecurityAgen
837932 isOrgDeleteFindingsPending ,
838933 isPersonalDeleteFindingsPending ,
839934 startingAnalysisIds ,
840- gitHubError ,
935+ providerState . gitHubError ,
841936 orphanedReposData ,
842937 triageModelSlug ,
843938 analysisModelSlug ,
0 commit comments