diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index c3e2b6129..64d14b62f 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -297,6 +297,7 @@ export const useCallControl = (props: useCallControlProps) => { agentId, } = props; const [isRecording, setIsRecording] = useState(true); + const [isHeld, setIsHeld] = useState(false); const [buddyAgents, setBuddyAgents] = useState([]); const [loadingBuddyAgents, setLoadingBuddyAgents] = useState(false); const [consultAgentName, setConsultAgentName] = useState('Consult Agent'); @@ -445,6 +446,13 @@ export const useCallControl = (props: useCallControlProps) => { } }, [currentTask, logger, lastTargetType, consultAgentName, setConsultAgentName]); + // Sync local hold state from controlVisibilityBase only when a different task is selected + // (not on every currentTask reference change, which would overwrite event-driven state) + useEffect(() => { + setIsHeld(controlVisibilityBase?.isHeld ?? false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTask?.data?.interactionId, agentId]); + // Extract main call timestamp whenever currentTask changes useEffect(() => { extractConsultingAgent(); @@ -530,6 +538,7 @@ export const useCallControl = (props: useCallControlProps) => { const holdCallback = () => { try { + setIsHeld(true); if (onHoldResume) { onHoldResume({ isHeld: true, @@ -546,6 +555,7 @@ export const useCallControl = (props: useCallControlProps) => { const resumeCallback = () => { try { + setIsHeld(false); if (onHoldResume) { onHoldResume({ isHeld: false, @@ -595,10 +605,12 @@ export const useCallControl = (props: useCallControlProps) => { const pauseRecordingCallback = () => { try { setIsRecording(false); - onRecordingToggle({ - isRecording: false, - task: currentTask, - }); + if (onRecordingToggle) { + onRecordingToggle({ + isRecording: false, + task: currentTask, + }); + } } catch (error) { logger?.error(`CC-Widgets: Task: Error in pauseRecordingCallback - ${error.message}`, { module: 'useCallControl', @@ -610,10 +622,12 @@ export const useCallControl = (props: useCallControlProps) => { const resumeRecordingCallback = () => { try { setIsRecording(true); - onRecordingToggle({ - isRecording: true, - task: currentTask, - }); + if (onRecordingToggle) { + onRecordingToggle({ + isRecording: true, + task: currentTask, + }); + } } catch (error) { logger?.error(`CC-Widgets: Task: Error in resumeRecordingCallback - ${error.message}`, { module: 'useCallControl', @@ -648,8 +662,8 @@ export const useCallControl = (props: useCallControlProps) => { store.removeTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId); store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); store.removeTaskCallback(TASK_EVENTS.AGENT_WRAPPEDUP, wrapupCallCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.CONTACT_RECORDING_PAUSED, pauseRecordingCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.CONTACT_RECORDING_RESUMED, resumeRecordingCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId); }; }, [currentTask]); @@ -927,11 +941,14 @@ export const useCallControl = (props: useCallControlProps) => { currentTask.cancelAutoWrapupTimer(); }; - const controlVisibility = useMemo( + const controlVisibilityBase = useMemo( () => getControlsVisibility(deviceType, featureFlags, currentTask, agentId, conferenceEnabled, logger), [deviceType, featureFlags, currentTask, agentId, conferenceEnabled, logger] ); + // Override isHeld with event-driven local state to avoid stale task data from getAllTasks() + const controlVisibility = useMemo(() => ({...controlVisibilityBase, isHeld}), [controlVisibilityBase, isHeld]); + // Add useEffect for auto wrap-up timer useEffect(() => { let timerId: NodeJS.Timeout; diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index a0c63d574..9d9d81099 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -957,6 +957,115 @@ describe('useCallControl', () => { expect(mockOnHoldResume).toHaveBeenCalledWith({isHeld: false, task: mockCurrentTask}); }); + it('should set controlVisibility.isHeld to true when holdCallback fires', async () => { + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + conferenceEnabled: true, + agentId: 'test-agent-id', + }) + ); + + // Initially isHeld should be false + expect(result.current.controlVisibility.isHeld).toBe(false); + + // Find the hold callback registered via setTaskCallback + const holdCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_HOLD)?.[1]; + + act(() => { + holdCallback(); + }); + + // After holdCallback fires, isHeld should be true + expect(result.current.controlVisibility.isHeld).toBe(true); + }); + + it('should set controlVisibility.isHeld to false when resumeCallback fires', async () => { + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + conferenceEnabled: true, + agentId: 'test-agent-id', + }) + ); + + // First hold the call + const holdCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_HOLD)?.[1]; + act(() => { + holdCallback(); + }); + expect(result.current.controlVisibility.isHeld).toBe(true); + + // Then resume — isHeld should go back to false + const resumeCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_RESUME)?.[1]; + act(() => { + resumeCallback(); + }); + + expect(result.current.controlVisibility.isHeld).toBe(false); + }); + + it('should override getControlsVisibility isHeld with event-driven state from callbacks', async () => { + // Mock getControlsVisibility to return isHeld: true (simulating stale task data after refreshTaskList) + mockGetControlsVisibility.mockReturnValue({ + ...mockGetControlsVisibility.mock.results[0]?.value, + muteUnmute: {isVisible: true, isEnabled: true}, + holdResume: {isVisible: true, isEnabled: true}, + end: {isVisible: true, isEnabled: true}, + isHeld: true, + wrapup: {isVisible: false, isEnabled: false}, + }); + + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + conferenceEnabled: true, + agentId: 'test-agent-id', + }) + ); + + // resumeCallback should override getControlsVisibility isHeld: true with false + const resumeCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_RESUME)?.[1]; + act(() => { + resumeCallback(); + }); + expect(result.current.controlVisibility.isHeld).toBe(false); + + // holdCallback should set it back to true + const holdCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_HOLD)?.[1]; + act(() => { + holdCallback(); + }); + expect(result.current.controlVisibility.isHeld).toBe(true); + }); + it('should log an error if hold fails', async () => { mockCurrentTask.hold.mockRejectedValueOnce(new Error('Hold error')); @@ -5730,6 +5839,64 @@ describe('Task Hook Error Handling and Logging', () => { ); }); + it('should not throw when onRecordingToggle is not provided and pauseRecordingCallback fires', () => { + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); + + renderHook(() => + useCallControl({ + currentTask: mockTaskWithInteraction, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + const pauseCallback = setTaskCallbackSpy.mock.calls.find( + (call) => call[0] === TASK_EVENTS.TASK_RECORDING_PAUSED + )?.[1]; + + act(() => { + pauseCallback(); + }); + + expect(logger.error).not.toHaveBeenCalledWith( + expect.stringContaining('pauseRecordingCallback'), + expect.any(Object) + ); + }); + + it('should not throw when onRecordingToggle is not provided and resumeRecordingCallback fires', () => { + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); + + renderHook(() => + useCallControl({ + currentTask: mockTaskWithInteraction, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + const resumeCallback = setTaskCallbackSpy.mock.calls.find( + (call) => call[0] === TASK_EVENTS.TASK_RECORDING_RESUMED + )?.[1]; + + act(() => { + resumeCallback(); + }); + + expect(logger.error).not.toHaveBeenCalledWith( + expect.stringContaining('resumeRecordingCallback'), + expect.any(Object) + ); + }); + it('should handle synchronous errors in toggleRecording', () => { const errorTask = { ...mockTaskWithInteraction,