@webex/contact-center 3.12.0-next.13 → 3.12.0-next.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/.sdd/manifest.json +884 -0
  2. package/AGENTS.md +94 -0
  3. package/ai-docs/ARCHITECTURE.md +168 -0
  4. package/ai-docs/CONTRACTS.md +53 -0
  5. package/ai-docs/GETTING_STARTED.md +168 -0
  6. package/ai-docs/GLOSSARY.md +43 -0
  7. package/ai-docs/README.md +138 -0
  8. package/ai-docs/REVIEW_CHECKLIST.md +41 -0
  9. package/ai-docs/RULES.md +444 -0
  10. package/ai-docs/SECURITY.md +52 -0
  11. package/ai-docs/SERVICE_STATE.md +52 -0
  12. package/ai-docs/SPEC_INDEX.md +65 -0
  13. package/ai-docs/adr/0001-spec-source-policy.md +55 -0
  14. package/ai-docs/adr/README.md +8 -0
  15. package/ai-docs/adr/_adr-template.md +31 -0
  16. package/ai-docs/contact-center-spec.md +382 -0
  17. package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
  18. package/ai-docs/features/contact-owner-change-refactor.md +78 -0
  19. package/ai-docs/features/generated-spec-conformance-fidelity-remediation/spec/feature-spec.md +117 -0
  20. package/ai-docs/features/residual-warning-coverage-completion/spec/feature-spec.md +203 -0
  21. package/ai-docs/features/validator-code-fidelity-drift-fix/spec/feature-spec.md +315 -0
  22. package/ai-docs/patterns/event-driven-patterns.md +485 -0
  23. package/ai-docs/patterns/testing-patterns.md +480 -0
  24. package/ai-docs/patterns/typescript-patterns.md +365 -0
  25. package/ai-docs/templates/README.md +102 -0
  26. package/ai-docs/templates/documentation/create-agents-md.md +240 -0
  27. package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
  28. package/ai-docs/templates/existing-service/bug-fix.md +254 -0
  29. package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
  30. package/ai-docs/templates/new-method/00-master.md +80 -0
  31. package/ai-docs/templates/new-method/01-requirements.md +232 -0
  32. package/ai-docs/templates/new-method/02-implementation.md +295 -0
  33. package/ai-docs/templates/new-method/03-tests.md +201 -0
  34. package/ai-docs/templates/new-method/04-validation.md +141 -0
  35. package/ai-docs/templates/new-service/00-master.md +109 -0
  36. package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
  37. package/ai-docs/templates/new-service/02-code-generation.md +346 -0
  38. package/ai-docs/templates/new-service/03-integration.md +178 -0
  39. package/ai-docs/templates/new-service/04-test-generation.md +205 -0
  40. package/ai-docs/templates/new-service/05-validation.md +145 -0
  41. package/dist/cc.js +971 -42
  42. package/dist/cc.js.map +1 -1
  43. package/dist/config.js +13 -0
  44. package/dist/config.js.map +1 -1
  45. package/dist/constants.js +31 -3
  46. package/dist/constants.js.map +1 -1
  47. package/dist/index.js +45 -15
  48. package/dist/index.js.map +1 -1
  49. package/dist/metrics/MetricsManager.js +16 -0
  50. package/dist/metrics/MetricsManager.js.map +1 -1
  51. package/dist/metrics/behavioral-events.js +205 -0
  52. package/dist/metrics/behavioral-events.js.map +1 -1
  53. package/dist/metrics/constants.js +48 -4
  54. package/dist/metrics/constants.js.map +1 -1
  55. package/dist/services/AddressBook.js +18 -15
  56. package/dist/services/AddressBook.js.map +1 -1
  57. package/dist/services/AnswerCallOnWebexService.js +202 -0
  58. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  59. package/dist/services/ApiAiAssistant.js +269 -9
  60. package/dist/services/ApiAiAssistant.js.map +1 -1
  61. package/dist/services/EntryPoint.js +46 -68
  62. package/dist/services/EntryPoint.js.map +1 -1
  63. package/dist/services/Queue.js +27 -22
  64. package/dist/services/Queue.js.map +1 -1
  65. package/dist/services/UserPreference.js +427 -0
  66. package/dist/services/UserPreference.js.map +1 -0
  67. package/dist/services/WebexCrossClientService.js +222 -0
  68. package/dist/services/WebexCrossClientService.js.map +1 -0
  69. package/dist/services/WxAppTelephonyMercurySync.js +108 -0
  70. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  71. package/dist/services/config/Util.js +11 -4
  72. package/dist/services/config/Util.js.map +1 -1
  73. package/dist/services/config/constants.js +41 -7
  74. package/dist/services/config/constants.js.map +1 -1
  75. package/dist/services/config/index.js +51 -0
  76. package/dist/services/config/index.js.map +1 -1
  77. package/dist/services/config/types.js +57 -12
  78. package/dist/services/config/types.js.map +1 -1
  79. package/dist/services/core/Err.js.map +1 -1
  80. package/dist/services/core/Utils.js +111 -32
  81. package/dist/services/core/Utils.js.map +1 -1
  82. package/dist/services/core/WebexRequest.js +6 -2
  83. package/dist/services/core/WebexRequest.js.map +1 -1
  84. package/dist/services/core/aqm-reqs.js +28 -14
  85. package/dist/services/core/aqm-reqs.js.map +1 -1
  86. package/dist/services/core/types.js.map +1 -1
  87. package/dist/services/core/websocket/WebSocketManager.js +2 -1
  88. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  89. package/dist/services/core/websocket/types.js.map +1 -1
  90. package/dist/services/index.js +1 -1
  91. package/dist/services/index.js.map +1 -1
  92. package/dist/services/task/Task.js +754 -0
  93. package/dist/services/task/Task.js.map +1 -0
  94. package/dist/services/task/TaskFactory.js +49 -0
  95. package/dist/services/task/TaskFactory.js.map +1 -0
  96. package/dist/services/task/TaskManager.js +1172 -521
  97. package/dist/services/task/TaskManager.js.map +1 -1
  98. package/dist/services/task/TaskUtils.js +211 -27
  99. package/dist/services/task/TaskUtils.js.map +1 -1
  100. package/dist/services/task/WebexCallingUtils.js +70 -0
  101. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  102. package/dist/services/task/constants.js +12 -2
  103. package/dist/services/task/constants.js.map +1 -1
  104. package/dist/services/task/contact.js +29 -0
  105. package/dist/services/task/contact.js.map +1 -1
  106. package/dist/services/task/dialer.js +78 -0
  107. package/dist/services/task/dialer.js.map +1 -1
  108. package/dist/services/task/digital/Digital.js +78 -0
  109. package/dist/services/task/digital/Digital.js.map +1 -0
  110. package/dist/services/task/state-machine/TaskStateMachine.js +982 -0
  111. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  112. package/dist/services/task/state-machine/actions.js +572 -0
  113. package/dist/services/task/state-machine/actions.js.map +1 -0
  114. package/dist/services/task/state-machine/constants.js +161 -0
  115. package/dist/services/task/state-machine/constants.js.map +1 -0
  116. package/dist/services/task/state-machine/guards.js +379 -0
  117. package/dist/services/task/state-machine/guards.js.map +1 -0
  118. package/dist/services/task/state-machine/index.js +53 -0
  119. package/dist/services/task/state-machine/index.js.map +1 -0
  120. package/dist/services/task/state-machine/types.js +54 -0
  121. package/dist/services/task/state-machine/types.js.map +1 -0
  122. package/dist/services/task/state-machine/uiControlsComputer.js +703 -0
  123. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  124. package/dist/services/task/taskDataNormalizer.js +99 -0
  125. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  126. package/dist/services/task/types.js +228 -5
  127. package/dist/services/task/types.js.map +1 -1
  128. package/dist/services/task/voice/Voice.js +1485 -0
  129. package/dist/services/task/voice/Voice.js.map +1 -0
  130. package/dist/services/task/voice/WebRTC.js +152 -0
  131. package/dist/services/task/voice/WebRTC.js.map +1 -0
  132. package/dist/services/task/voice/wxAppVoiceMethods.js +354 -0
  133. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  134. package/dist/services/wxAppDiagnosticLogging.js +122 -0
  135. package/dist/services/wxAppDiagnosticLogging.js.map +1 -0
  136. package/dist/services/wxAppTelephonyUtils.js +19 -0
  137. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  138. package/dist/types/cc.d.ts +193 -6
  139. package/dist/types/config.d.ts +13 -0
  140. package/dist/types/constants.d.ts +29 -1
  141. package/dist/types/index.d.ts +35 -8
  142. package/dist/types/metrics/MetricsManager.d.ts +6 -0
  143. package/dist/types/metrics/constants.d.ts +43 -1
  144. package/dist/types/services/AddressBook.d.ts +2 -1
  145. package/dist/types/services/AnswerCallOnWebexService.d.ts +38 -0
  146. package/dist/types/services/ApiAiAssistant.d.ts +56 -5
  147. package/dist/types/services/EntryPoint.d.ts +7 -5
  148. package/dist/types/services/Queue.d.ts +5 -3
  149. package/dist/types/services/UserPreference.d.ts +118 -0
  150. package/dist/types/services/WebexCrossClientService.d.ts +32 -0
  151. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  152. package/dist/types/services/config/constants.d.ts +34 -5
  153. package/dist/types/services/config/types.d.ts +197 -12
  154. package/dist/types/services/core/Err.d.ts +6 -0
  155. package/dist/types/services/core/Utils.d.ts +35 -13
  156. package/dist/types/services/core/WebexRequest.d.ts +1 -0
  157. package/dist/types/services/core/types.d.ts +2 -0
  158. package/dist/types/services/core/websocket/WebSocketManager.d.ts +1 -0
  159. package/dist/types/services/core/websocket/types.d.ts +1 -1
  160. package/dist/types/services/index.d.ts +1 -1
  161. package/dist/types/services/task/Task.d.ts +175 -0
  162. package/dist/types/services/task/TaskFactory.d.ts +13 -0
  163. package/dist/types/services/task/TaskUtils.d.ts +48 -2
  164. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  165. package/dist/types/services/task/constants.d.ts +10 -0
  166. package/dist/types/services/task/contact.d.ts +4 -0
  167. package/dist/types/services/task/dialer.d.ts +30 -0
  168. package/dist/types/services/task/digital/Digital.d.ts +22 -0
  169. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1442 -0
  170. package/dist/types/services/task/state-machine/actions.d.ts +10 -0
  171. package/dist/types/services/task/state-machine/constants.d.ts +107 -0
  172. package/dist/types/services/task/state-machine/guards.d.ts +103 -0
  173. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  174. package/dist/types/services/task/state-machine/types.d.ts +277 -0
  175. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  176. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  177. package/dist/types/services/task/types.d.ts +680 -66
  178. package/dist/types/services/task/voice/Voice.d.ts +227 -0
  179. package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
  180. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +55 -0
  181. package/dist/types/services/wxAppDiagnosticLogging.d.ts +51 -0
  182. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  183. package/dist/types/types.d.ts +205 -9
  184. package/dist/types/utils/PageCache.d.ts +20 -3
  185. package/dist/types/webex.d.ts +1 -0
  186. package/dist/types.js +162 -4
  187. package/dist/types.js.map +1 -1
  188. package/dist/utils/PageCache.js +19 -5
  189. package/dist/utils/PageCache.js.map +1 -1
  190. package/dist/webex.js +22 -2
  191. package/dist/webex.js.map +1 -1
  192. package/package.json +16 -12
  193. package/src/cc.ts +1289 -112
  194. package/src/config.ts +13 -0
  195. package/src/constants.ts +29 -1
  196. package/src/index.ts +36 -5
  197. package/src/metrics/MetricsManager.ts +16 -0
  198. package/src/metrics/ai-docs/AGENTS.md +350 -0
  199. package/src/metrics/ai-docs/ARCHITECTURE.md +338 -0
  200. package/src/metrics/ai-docs/metrics-spec.md +921 -0
  201. package/src/metrics/behavioral-events.ts +212 -0
  202. package/src/metrics/constants.ts +52 -4
  203. package/src/services/AddressBook.ts +17 -6
  204. package/src/services/AnswerCallOnWebexService.ts +239 -0
  205. package/src/services/ApiAiAssistant.ts +356 -9
  206. package/src/services/EntryPoint.ts +59 -60
  207. package/src/services/Queue.ts +29 -12
  208. package/src/services/UserPreference.ts +509 -0
  209. package/src/services/WebexCrossClientService.ts +293 -0
  210. package/src/services/WxAppTelephonyMercurySync.ts +142 -0
  211. package/src/services/agent/ai-docs/AGENTS.md +240 -0
  212. package/src/services/agent/ai-docs/ARCHITECTURE.md +304 -0
  213. package/src/services/agent/ai-docs/agent-spec.md +505 -0
  214. package/src/services/ai-docs/AGENTS.md +386 -0
  215. package/src/services/ai-docs/services-spec.md +505 -0
  216. package/src/services/config/Util.ts +12 -3
  217. package/src/services/config/ai-docs/AGENTS.md +255 -0
  218. package/src/services/config/ai-docs/ARCHITECTURE.md +426 -0
  219. package/src/services/config/ai-docs/config-spec.md +686 -0
  220. package/src/services/config/constants.ts +46 -6
  221. package/src/services/config/index.ts +65 -0
  222. package/src/services/config/types.ts +197 -13
  223. package/src/services/core/Err.ts +3 -0
  224. package/src/services/core/Utils.ts +127 -37
  225. package/src/services/core/WebexRequest.ts +3 -1
  226. package/src/services/core/ai-docs/AGENTS.md +381 -0
  227. package/src/services/core/ai-docs/ARCHITECTURE.md +698 -0
  228. package/src/services/core/ai-docs/core-spec.md +787 -0
  229. package/src/services/core/aqm-reqs.ts +30 -15
  230. package/src/services/core/types.ts +2 -0
  231. package/src/services/core/websocket/WebSocketManager.ts +2 -0
  232. package/src/services/core/websocket/types.ts +1 -1
  233. package/src/services/index.ts +1 -1
  234. package/src/services/task/Task.ts +908 -0
  235. package/src/services/task/TaskFactory.ts +60 -0
  236. package/src/services/task/TaskManager.ts +1419 -608
  237. package/src/services/task/TaskUtils.ts +303 -25
  238. package/src/services/task/WebexCallingUtils.ts +136 -0
  239. package/src/services/task/ai-docs/AGENTS.md +457 -0
  240. package/src/services/task/ai-docs/ARCHITECTURE.md +595 -0
  241. package/src/services/task/ai-docs/task-spec.md +1474 -0
  242. package/src/services/task/constants.ts +10 -0
  243. package/src/services/task/contact.ts +30 -0
  244. package/src/services/task/dialer.ts +80 -0
  245. package/src/services/task/digital/Digital.ts +97 -0
  246. package/src/services/task/state-machine/TaskStateMachine.ts +1320 -0
  247. package/src/services/task/state-machine/actions.ts +741 -0
  248. package/src/services/task/state-machine/ai-docs/AGENTS.md +460 -0
  249. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1156 -0
  250. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +2247 -0
  251. package/src/services/task/state-machine/constants.ts +172 -0
  252. package/src/services/task/state-machine/guards.ts +442 -0
  253. package/src/services/task/state-machine/index.ts +28 -0
  254. package/src/services/task/state-machine/types.ts +258 -0
  255. package/src/services/task/state-machine/uiControlsComputer.ts +1135 -0
  256. package/src/services/task/taskDataNormalizer.ts +137 -0
  257. package/src/services/task/types.ts +819 -70
  258. package/src/services/task/voice/Voice.ts +1851 -0
  259. package/src/services/task/voice/WebRTC.ts +191 -0
  260. package/src/services/task/voice/wxAppVoiceMethods.ts +516 -0
  261. package/src/services/wxAppDiagnosticLogging.ts +174 -0
  262. package/src/services/wxAppTelephonyUtils.ts +14 -0
  263. package/src/types.ts +262 -11
  264. package/src/utils/AGENTS.md +289 -0
  265. package/src/utils/PageCache.ts +38 -5
  266. package/src/utils/ai-docs/utils-spec.md +391 -0
  267. package/src/webex.js +10 -0
  268. package/test/unit/spec/cc.ts +2210 -119
  269. package/test/unit/spec/logger-proxy.ts +70 -0
  270. package/test/unit/spec/metrics/MetricsManager.ts +25 -0
  271. package/test/unit/spec/metrics/behavioral-events.ts +60 -0
  272. package/test/unit/spec/services/AddressBook.ts +37 -6
  273. package/test/unit/spec/services/AnswerCallOnWebexService.ts +264 -0
  274. package/test/unit/spec/services/ApiAiAssistant.ts +311 -21
  275. package/test/unit/spec/services/EntryPoint.ts +87 -40
  276. package/test/unit/spec/services/Queue.ts +123 -12
  277. package/test/unit/spec/services/UserPreference.ts +401 -0
  278. package/test/unit/spec/services/WebCallingService.ts +7 -1
  279. package/test/unit/spec/services/WebexCrossClientService.ts +467 -0
  280. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +136 -0
  281. package/test/unit/spec/services/config/Util.ts +129 -0
  282. package/test/unit/spec/services/config/index.ts +97 -29
  283. package/test/unit/spec/services/core/Utils.ts +425 -8
  284. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  285. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  286. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +66 -40
  287. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  288. package/test/unit/spec/services/task/Task.ts +745 -0
  289. package/test/unit/spec/services/task/TaskFactory.ts +99 -0
  290. package/test/unit/spec/services/task/TaskManager.ts +2518 -1494
  291. package/test/unit/spec/services/task/TaskUtils.ts +235 -0
  292. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  293. package/test/unit/spec/services/task/contact.ts +33 -0
  294. package/test/unit/spec/services/task/dialer.ts +190 -0
  295. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  296. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +4060 -0
  297. package/test/unit/spec/services/task/state-machine/guards.ts +839 -0
  298. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  299. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +3101 -0
  300. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  301. package/test/unit/spec/services/task/voice/Voice.ts +2061 -0
  302. package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
  303. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +603 -0
  304. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +143 -0
  305. package/test/unit/spec/wellnessSampleUtils.ts +288 -0
  306. package/umd/contact-center.min.js +2 -2
  307. package/umd/contact-center.min.js.map +1 -1
  308. package/dist/services/task/index.js +0 -1525
  309. package/dist/services/task/index.js.map +0 -1
  310. package/dist/types/services/task/index.d.ts +0 -650
  311. package/src/services/task/index.ts +0 -1801
  312. package/test/unit/spec/services/task/index.ts +0 -2184
@@ -0,0 +1,1135 @@
1
+ /**
2
+ * UI Controls Computer - Centralized logic for computing UI control states
3
+ */
4
+
5
+ import {
6
+ CONSULT_TRANSFER_DESTINATION_TYPE,
7
+ InteractionUIControls,
8
+ TASK_CHANNEL_TYPE,
9
+ TaskData,
10
+ TaskUILeg,
11
+ TaskUIControls,
12
+ VOICE_VARIANT,
13
+ } from '../types';
14
+ import {TaskContext, UIControlConfig} from './types';
15
+ import {
16
+ getWebexCallingDeviceDetailsForAgent,
17
+ isWxAppEngagedForControls,
18
+ } from '../WebexCallingUtils';
19
+ import {TaskState, MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE, INTERACTION_STATE} from './constants';
20
+ import {
21
+ getIsCustomerInCall,
22
+ getConferenceParticipantsCount,
23
+ getIsConferenceInProgress,
24
+ getIsConsultInProgressForConferenceControls,
25
+ getIsConsultedAgentForControls,
26
+ getServerHoldStateForControls,
27
+ } from '../TaskUtils';
28
+ import {INBOUND_DIRECTION, OUTDIAL_DIRECTION} from '../../../constants';
29
+ import {COLLABORATION_ACCESS} from '../../config/constants';
30
+
31
+ type DestinationControls = TaskUIControls['consultTransferDestinations'];
32
+
33
+ const DISABLED = {isVisible: false, isEnabled: false} as const;
34
+ const VISIBLE_ENABLED = {isVisible: true, isEnabled: true} as const;
35
+ const VISIBLE_DISABLED = {isVisible: true, isEnabled: false} as const;
36
+
37
+ /** wxApp telephony controls live on the main leg only (mute/keypad via wxApp APIs). */
38
+ function withWxAppTelephonyControls(
39
+ controls: InteractionUIControls,
40
+ isWxAppEngaged: boolean,
41
+ currentLeg: TaskUILeg,
42
+ isWrappingUp: boolean
43
+ ): InteractionUIControls {
44
+ if (!isWxAppEngaged || isWrappingUp) {
45
+ return controls;
46
+ }
47
+
48
+ if (currentLeg === 'main') {
49
+ return {
50
+ ...controls,
51
+ mute: VISIBLE_ENABLED,
52
+ keypad: VISIBLE_ENABLED,
53
+ };
54
+ }
55
+
56
+ return {
57
+ ...controls,
58
+ mute: DISABLED,
59
+ keypad: DISABLED,
60
+ };
61
+ }
62
+
63
+ function getDefaultInteractionUIControls(): InteractionUIControls {
64
+ return {
65
+ accept: DISABLED,
66
+ decline: DISABLED,
67
+ hold: DISABLED,
68
+ mute: DISABLED,
69
+ end: DISABLED,
70
+ transfer: DISABLED,
71
+ consult: DISABLED,
72
+ consultTransfer: DISABLED,
73
+ endConsult: DISABLED,
74
+ recording: DISABLED,
75
+ conference: DISABLED,
76
+ wrapup: DISABLED,
77
+ exitConference: DISABLED,
78
+ transferConference: DISABLED,
79
+ mergeToConference: DISABLED,
80
+ switch: DISABLED,
81
+ keypad: DISABLED,
82
+ };
83
+ }
84
+
85
+ function createTaskUIControls(
86
+ main: InteractionUIControls,
87
+ consult: InteractionUIControls,
88
+ activeLeg: TaskUILeg,
89
+ consultTransferDestinations: DestinationControls = {
90
+ consult: [],
91
+ transfer: [],
92
+ }
93
+ ): TaskUIControls {
94
+ return {
95
+ main,
96
+ consult,
97
+ activeLeg,
98
+ consultTransferDestinations,
99
+ };
100
+ }
101
+
102
+ export function getDefaultUIControls(): TaskUIControls {
103
+ return createTaskUIControls(
104
+ getDefaultInteractionUIControls(),
105
+ getDefaultInteractionUIControls(),
106
+ 'main'
107
+ );
108
+ }
109
+
110
+ function getConsultTransferDestinations(
111
+ config: UIControlConfig,
112
+ taskData?: TaskData | null
113
+ ): DestinationControls {
114
+ const destinationConfig = config.consultTransferConfig;
115
+ if (!destinationConfig) {
116
+ return {consult: [], transfer: []};
117
+ }
118
+
119
+ const isVoice = config.channelType === TASK_CHANNEL_TYPE.VOICE;
120
+ const interaction = taskData?.interaction;
121
+ const direction = interaction?.contactDirection?.type?.toUpperCase();
122
+ const destinations: DestinationControls = {
123
+ consult: [],
124
+ transfer: [],
125
+ };
126
+
127
+ if (destinationConfig.accessBuddyTeam?.toUpperCase() !== COLLABORATION_ACCESS.NONE) {
128
+ destinations.consult.push(CONSULT_TRANSFER_DESTINATION_TYPE.AGENT);
129
+ destinations.transfer.push(CONSULT_TRANSFER_DESTINATION_TYPE.AGENT);
130
+ }
131
+
132
+ if (destinationConfig.accessQueue?.toUpperCase() !== COLLABORATION_ACCESS.NONE) {
133
+ const canConsultQueue = !isVoice || destinationConfig.allowConsultToQueue;
134
+ const canTransferQueue =
135
+ !isVoice ||
136
+ direction === INBOUND_DIRECTION ||
137
+ (direction === OUTDIAL_DIRECTION &&
138
+ interaction?.callProcessingDetails?.outdialTransferToQueueEnabled === true);
139
+
140
+ if (canConsultQueue) destinations.consult.push(CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE);
141
+ if (canTransferQueue) destinations.transfer.push(CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE);
142
+ }
143
+
144
+ if (isVoice) {
145
+ destinations.consult.push(CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER);
146
+ destinations.transfer.push(CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER);
147
+
148
+ if (destinationConfig.accessEntryPoint?.toUpperCase() !== COLLABORATION_ACCESS.NONE) {
149
+ destinations.consult.push(CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT);
150
+ destinations.transfer.push(CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT);
151
+ }
152
+ }
153
+
154
+ return destinations;
155
+ }
156
+
157
+ /** Consult media must exist on the interaction payload, not only as a stale resource id. */
158
+ function hasConsultMediaInInteraction(
159
+ interaction: TaskData['interaction'] | undefined,
160
+ consultMediaResourceId?: string
161
+ ): boolean {
162
+ const media = interaction?.media ?? {};
163
+ const hasConsultLeg = Object.values(media).some((entry: any) => entry?.mType === 'consult');
164
+
165
+ if (hasConsultLeg) return true;
166
+ if (!consultMediaResourceId) return false;
167
+
168
+ return Boolean(media[consultMediaResourceId]);
169
+ }
170
+
171
+ /** True when this agent is actively initiating a consult, not a stale context flag alone (CAI-8329). */
172
+ function isSelfActiveConsultInitiator(
173
+ consultInitiator: boolean,
174
+ selfAgentId: string | undefined,
175
+ selfParticipant: {consultState?: string} | null | undefined,
176
+ selfInConsultCall: boolean,
177
+ consultingAgentId?: string
178
+ ): boolean {
179
+ return (
180
+ consultInitiator &&
181
+ (selfParticipant?.consultState === 'consultInitiated' ||
182
+ selfParticipant?.consultState === 'consulting' ||
183
+ selfInConsultCall ||
184
+ (Boolean(consultingAgentId) && consultingAgentId === selfAgentId))
185
+ );
186
+ }
187
+
188
+ function computeVoiceInteractionUIControls(
189
+ state: TaskState,
190
+ context: TaskContext,
191
+ config: UIControlConfig,
192
+ fallbackTaskData?: TaskData,
193
+ currentLeg: TaskUILeg = 'main'
194
+ ): InteractionUIControls {
195
+ // Early exit for idle
196
+ if (state === TaskState.IDLE) {
197
+ return getDefaultInteractionUIControls();
198
+ }
199
+
200
+ // Prefer live task.data (fallback) over stale state-machine snapshot during multi-login sync.
201
+ const taskData = fallbackTaskData ?? context.taskData ?? null;
202
+ const interaction = taskData?.interaction;
203
+ const mainCallId = interaction?.mainInteractionId || taskData?.interactionId;
204
+ const isWebrtc = config.voiceVariant === VOICE_VARIANT.WEBRTC;
205
+ const isOutdial = interaction?.outboundType === 'OUTDIAL';
206
+ const serverHold = getServerHoldStateForControls(context, mainCallId, fallbackTaskData);
207
+
208
+ // Backend-derived checks
209
+ const customerInCall =
210
+ interaction && mainCallId ? getIsCustomerInCall(interaction, mainCallId) : false;
211
+ // EP-DN/secondary legs can have incomplete media participant lists; fall back to participants map.
212
+ const customerPresent =
213
+ customerInCall ||
214
+ Boolean(
215
+ interaction &&
216
+ interaction.participants &&
217
+ Object.values(interaction.participants).some(
218
+ (p: any) => p?.pType === 'Customer' && !p?.hasLeft
219
+ )
220
+ );
221
+ const participantCount =
222
+ interaction && mainCallId ? getConferenceParticipantsCount(interaction, mainCallId) : 0;
223
+ const maxParticipants = participantCount >= MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE;
224
+ const selfAgentId = config.agentId ?? taskData?.agentId;
225
+ const enableWxBetterTogether = config.enableWxBetterTogether ?? false;
226
+ const wxAppDeviceDetails = enableWxBetterTogether
227
+ ? getWebexCallingDeviceDetailsForAgent(selfAgentId, interaction?.participants)
228
+ : undefined;
229
+ const isWxAppParticipant = enableWxBetterTogether && wxAppDeviceDetails?.deviceType === 'wxApp';
230
+ const isWxAppOffer =
231
+ isWxAppParticipant && Boolean(wxAppDeviceDetails) && state === TaskState.OFFERED;
232
+ const isWxAppInboundOffer = isWxAppOffer && !isOutdial;
233
+ const isWxAppOutdialOffer = isWxAppOffer && isOutdial;
234
+ const wxAppAnswerPending = config.wxAppAnswerPending ?? false;
235
+ const wxAppAcceptInFlight = config.wxAppAcceptInFlight ?? false;
236
+ const isWxAppEngaged = isWxAppEngagedForControls(
237
+ enableWxBetterTogether,
238
+ selfAgentId,
239
+ interaction?.participants,
240
+ state
241
+ );
242
+ const consultInProgress = getIsConsultInProgressForConferenceControls(
243
+ interaction,
244
+ mainCallId,
245
+ selfAgentId
246
+ );
247
+ const conferenceFromBackend = taskData ? getIsConferenceInProgress(taskData) : false;
248
+ // Note: ownership is used by some controls; keep computations local to those controls
249
+
250
+ // Context flags (set by state machine actions)
251
+ const {
252
+ consultInitiator,
253
+ consultDestinationAgentJoined,
254
+ consultDestinationType,
255
+ consultCallHeld,
256
+ consultFromConference,
257
+ hideBlindTransferForEpDnPendingMerge,
258
+ } = context;
259
+ const {recordingControlsAvailable} = context;
260
+
261
+ // EP_DN consults are "ready" as soon as the consult is created (EP accepts routing immediately).
262
+ // Backend sends destinationType as 'EP-DN'; SDK method uses 'entryPoint' — check both.
263
+ const isEpDnConsult =
264
+ consultDestinationType === 'entryPoint' || consultDestinationType === ('EP-DN' as any);
265
+
266
+ const stateImpliesHeld = state === TaskState.HELD || state === TaskState.RESUME_INITIATING;
267
+ const stateImpliesConnected =
268
+ state === TaskState.CONNECTED || state === TaskState.HOLD_INITIATING;
269
+ const isHeld = stateImpliesHeld || serverHold === true;
270
+ const isConnected = stateImpliesConnected || (!stateImpliesHeld && serverHold === false);
271
+
272
+ // State categories for cleaner logic
273
+ const isConsulting =
274
+ state === TaskState.CONSULTING ||
275
+ state === TaskState.CONSULT_INITIATING ||
276
+ state === TaskState.CONF_INITIATING;
277
+ const isConferencing = state === TaskState.CONFERENCING;
278
+ const isWrappingUp = state === TaskState.WRAPPING_UP;
279
+ const selfInMainCall =
280
+ Boolean(selfAgentId) &&
281
+ Boolean(mainCallId) &&
282
+ Boolean(interaction?.media?.[mainCallId]?.participants?.includes(selfAgentId as string));
283
+ const consultMedia = Object.values(interaction?.media ?? {}).find(
284
+ (media: any) =>
285
+ media?.mediaResourceId === taskData?.consultMediaResourceId || media?.mType === 'consult'
286
+ ) as {participants?: string[]} | undefined;
287
+ const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
288
+ const selfInConsultCall =
289
+ Boolean(selfAgentId) && Boolean(consultMedia?.participants?.includes(selfAgentId as string));
290
+ const hasConsultMedia = hasConsultMediaInInteraction(
291
+ interaction,
292
+ taskData?.consultMediaResourceId
293
+ );
294
+ const isSecondaryConsultRejectedWhileEpDnActive =
295
+ taskData?.type === 'AgentConsultFailed' && hideBlindTransferForEpDnPendingMerge;
296
+ const isSelfConsultCompleted =
297
+ selfParticipant?.consultState === 'consultCompleted' && taskData?.isConsulted === false;
298
+ const isConsultEndedForSelf =
299
+ taskData?.type === 'AgentConsultEnded' ||
300
+ (taskData?.type === 'AgentConsultFailed' && !isSecondaryConsultRejectedWhileEpDnActive) ||
301
+ isSelfConsultCompleted;
302
+ // EP-DN consult merged to conference before destination joins main (CAI-8329).
303
+ // Context flag is set in actions.ts; treat as main-leg conference UI only here.
304
+ const isEpDnPendingConferenceMergeUi =
305
+ hideBlindTransferForEpDnPendingMerge &&
306
+ participantCount <= 1 &&
307
+ (isConferencing ||
308
+ conferenceFromBackend ||
309
+ interaction?.state === INTERACTION_STATE.POST_CALL) &&
310
+ !isConsultEndedForSelf;
311
+ const isEpDnPostCallCustomerLeft = isEpDnPendingConferenceMergeUi && !customerPresent;
312
+ const effectiveConsultInProgress = isEpDnPendingConferenceMergeUi ? false : consultInProgress;
313
+ const effectiveConsultInitiator = isConsultEndedForSelf ? false : consultInitiator;
314
+ const effectiveConsultCallHeld = isConsultEndedForSelf ? false : consultCallHeld;
315
+ const effectiveConsultFromConference = isConsultEndedForSelf ? false : consultFromConference;
316
+ const effectiveConsultDestinationAgentJoined = isConsultEndedForSelf
317
+ ? false
318
+ : consultDestinationAgentJoined;
319
+ // After a consult ends for self, the backend stops sending consult media but the merged
320
+ // task.data can still carry the stale consult-media entry (reconcileData never deletes keys).
321
+ // Treat it as gone so post-consult main-leg controls (consult retry) are not blocked.
322
+ const effectiveHasConsultMedia = isConsultEndedForSelf ? false : hasConsultMedia;
323
+ const isConsultDestinationReady = effectiveConsultDestinationAgentJoined || isEpDnConsult;
324
+ const conferenceActive =
325
+ isConferencing || conferenceFromBackend || effectiveConsultFromConference;
326
+ // Treat consult initiator as "in conference" even if mainCall participant list lags while consulting.
327
+ const inConference =
328
+ conferenceActive && (isConferencing || selfInMainCall || effectiveConsultInitiator);
329
+
330
+ // Check if this is a consulted agent (must be after isConsulting is computed).
331
+ const isSoleAgentOnCall =
332
+ participantCount <= 1 && selfInMainCall && !isConsulting && !inConference;
333
+ const isConsulted =
334
+ inConference || isSoleAgentOnCall
335
+ ? false
336
+ : getIsConsultedAgentForControls(taskData, context, isConsulting) ||
337
+ (!effectiveConsultInitiator &&
338
+ (selfParticipant?.isConsulted === true ||
339
+ selfParticipant?.consultState === 'consulting'));
340
+
341
+ // Active call = can perform call operations
342
+ const isActive =
343
+ state === TaskState.CONNECTED ||
344
+ state === TaskState.HELD ||
345
+ state === TaskState.HOLD_INITIATING ||
346
+ state === TaskState.RESUME_INITIATING ||
347
+ isConsulting ||
348
+ isConferencing;
349
+
350
+ // Consulted agents have limited controls until they're in conference or wrapup
351
+ // Use inConference (not isConferencing) so controls remain enabled after state downgrade
352
+ const hasFullControls = !isConsulted || effectiveConsultInitiator || inConference || isWrappingUp;
353
+ const selfActiveConsultInitiator = isSelfActiveConsultInitiator(
354
+ effectiveConsultInitiator,
355
+ selfAgentId,
356
+ selfParticipant,
357
+ selfInConsultCall,
358
+ taskData?.consultingAgentId
359
+ );
360
+ const consultOwnedBySelf = !isEpDnPendingConferenceMergeUi && selfActiveConsultInitiator;
361
+ const selfConsultPendingOnConsultMedia =
362
+ selfParticipant?.consultState === 'consultInitiated' &&
363
+ !taskData?.isConsulted &&
364
+ hasConsultMedia;
365
+ const ownerParticipant = interaction?.owner
366
+ ? interaction.participants?.[interaction.owner]
367
+ : undefined;
368
+ const otherAgentConsultInProgress = Boolean(
369
+ interaction?.participants &&
370
+ Object.values(interaction.participants).some((participant: any) => {
371
+ if (!participant || participant.hasLeft) return false;
372
+ if (participant.id === selfAgentId) return false;
373
+ if (String(participant.pType ?? '').toUpperCase() !== 'AGENT') return false;
374
+
375
+ return (
376
+ participant.consultState === 'consultInitiated' ||
377
+ participant.consultState === 'consultReserved' ||
378
+ participant.consultState === 'consulting' ||
379
+ participant.currentState === 'consulting'
380
+ );
381
+ })
382
+ );
383
+ const isHydratedConferenceConsultPending =
384
+ inConference && selfConsultPendingOnConsultMedia && !effectiveConsultDestinationAgentJoined;
385
+ const hasParallelConsultLeg =
386
+ !isConsultEndedForSelf &&
387
+ consultOwnedBySelf &&
388
+ !isConsulting &&
389
+ !isConsulted &&
390
+ (effectiveConsultInProgress || effectiveConsultCallHeld || hasConsultMedia);
391
+ const activeLegForConferenceConsult = effectiveConsultCallHeld ? 'main' : 'consult';
392
+ const isCurrentLegActive = currentLeg === activeLegForConferenceConsult;
393
+ const isConferenceConsultTransferContext =
394
+ inConference && selfActiveConsultInitiator && hasConsultMedia && isConsultDestinationReady;
395
+ const consultLegOnHold = isConsulting && effectiveConsultCallHeld;
396
+ const callProcessingDetails = interaction?.callProcessingDetails as
397
+ | {conferenceHoldParticipant?: boolean | string}
398
+ | undefined;
399
+ const conferenceHoldParticipant =
400
+ callProcessingDetails?.conferenceHoldParticipant === true ||
401
+ callProcessingDetails?.conferenceHoldParticipant === 'true';
402
+ const postDeclineHeldMainLeg =
403
+ consultInitiator &&
404
+ !consultDestinationAgentJoined &&
405
+ isHeld &&
406
+ inConference &&
407
+ conferenceHoldParticipant;
408
+ const postConsultCompletedHeldMainLeg =
409
+ selfParticipant?.consultState === 'consultCompleted' && isHeld && inConference && !isConsulting;
410
+ const nonOwnerPostConsultCompletedHeldMainLeg =
411
+ isHeld &&
412
+ inConference &&
413
+ !isConsulting &&
414
+ !consultInitiator &&
415
+ Boolean(selfAgentId) &&
416
+ Boolean(interaction?.owner) &&
417
+ selfAgentId !== interaction?.owner &&
418
+ ownerParticipant?.consultState === 'consultCompleted';
419
+ const isConsultPendingBeforeJoin =
420
+ selfParticipant?.consultState === 'consultInitiated' && !consultDestinationAgentJoined;
421
+ const hideExitConferenceWhileConsultPending =
422
+ currentLeg === 'main' &&
423
+ inConference &&
424
+ isConsultPendingBeforeJoin &&
425
+ (consultFromConference ||
426
+ consultInitiator ||
427
+ taskData?.type === 'AgentConsultCreated' ||
428
+ effectiveConsultInProgress ||
429
+ isConsulting);
430
+ const hideExitConferenceDuringActiveConsultFromConference =
431
+ inConference &&
432
+ selfActiveConsultInitiator &&
433
+ consultDestinationAgentJoined &&
434
+ (isConsulting ||
435
+ taskData?.type === 'AgentConsulting' ||
436
+ selfParticipant?.consultState === 'consulting');
437
+ const hideExitConferenceOnMainLegForEpDnConsultFromConference =
438
+ currentLeg === 'main' &&
439
+ inConference &&
440
+ consultFromConference &&
441
+ selfActiveConsultInitiator &&
442
+ (isConsulting ||
443
+ effectiveConsultInProgress ||
444
+ taskData?.type === 'AgentConsultCreated' ||
445
+ taskData?.type === 'AgentConsulting' ||
446
+ selfParticipant?.consultState === 'consultInitiated' ||
447
+ selfParticipant?.consultState === 'consulting');
448
+ const forceHeldPostConsultControls =
449
+ !hideExitConferenceWhileConsultPending &&
450
+ (postDeclineHeldMainLeg || postConsultCompletedHeldMainLeg);
451
+ const selfOnConsultLeg =
452
+ selfParticipant?.consultState === 'consulting' ||
453
+ selfParticipant?.currentState === 'consulting';
454
+ const showMainLegConferenceControlsDuringConsult =
455
+ currentLeg === 'main' && inConference && effectiveConsultInProgress && !selfOnConsultLeg;
456
+ const allowHeldMainLegControlsForNonInitiator =
457
+ showMainLegConferenceControlsDuringConsult && !isHydratedConferenceConsultPending;
458
+ const isConsultRequestedPhase =
459
+ isConsultPendingBeforeJoin &&
460
+ !isConsulted &&
461
+ (consultInitiator ||
462
+ (Boolean(selfAgentId) &&
463
+ selfParticipant?.consultState === 'consultInitiated' &&
464
+ taskData?.agentId === selfAgentId));
465
+ const isConsultUnansweredFailure =
466
+ currentLeg === 'main' &&
467
+ !inConference &&
468
+ !taskData?.isConsulted &&
469
+ !effectiveHasConsultMedia &&
470
+ isHeld &&
471
+ selfParticipant?.consultState === 'consultCompleted' &&
472
+ (taskData?.type === 'AgentConsultFailed' ||
473
+ taskData?.type === 'AgentConsultEnded' ||
474
+ isConsultEndedForSelf);
475
+ // EP-DN consult merged to conference before a second agent joins main: hide blind transfer (CAI-8329).
476
+ const shouldHideBlindTransferForEpDnPendingConferenceMerge = isEpDnPendingConferenceMergeUi;
477
+
478
+ if (isConsultUnansweredFailure) {
479
+ const recordingControl =
480
+ recordingControlsAvailable && config.isRecordingEnabled && !inConference
481
+ ? VISIBLE_ENABLED
482
+ : DISABLED;
483
+
484
+ return withWxAppTelephonyControls(
485
+ {
486
+ ...getDefaultInteractionUIControls(),
487
+ hold: VISIBLE_ENABLED,
488
+ transfer: VISIBLE_ENABLED,
489
+ consult: VISIBLE_ENABLED,
490
+ recording: recordingControl,
491
+ end: VISIBLE_DISABLED,
492
+ },
493
+ isWxAppEngaged,
494
+ currentLeg,
495
+ isWrappingUp
496
+ );
497
+ }
498
+
499
+ if (isConsultRequestedPhase && !isEpDnPendingConferenceMergeUi) {
500
+ if (currentLeg === 'main') {
501
+ return withWxAppTelephonyControls(
502
+ {
503
+ ...getDefaultInteractionUIControls(),
504
+ transfer: shouldHideBlindTransferForEpDnPendingConferenceMerge
505
+ ? DISABLED
506
+ : VISIBLE_DISABLED,
507
+ conference: VISIBLE_DISABLED,
508
+ end: VISIBLE_DISABLED,
509
+ },
510
+ isWxAppEngaged,
511
+ currentLeg,
512
+ isWrappingUp
513
+ );
514
+ }
515
+
516
+ if (currentLeg === 'consult') {
517
+ return withWxAppTelephonyControls(
518
+ {
519
+ ...getDefaultInteractionUIControls(),
520
+ endConsult: VISIBLE_ENABLED,
521
+ switch: VISIBLE_DISABLED,
522
+ transfer: VISIBLE_DISABLED,
523
+ transferConference: inConference ? VISIBLE_DISABLED : DISABLED,
524
+ mergeToConference: VISIBLE_DISABLED,
525
+ },
526
+ isWxAppEngaged,
527
+ currentLeg,
528
+ isWrappingUp
529
+ );
530
+ }
531
+ }
532
+
533
+ return {
534
+ // Accept/Decline: Voice tasks in offered state
535
+ // Desktop/WebRTC + inbound: accept enabled (agent manually accepts)
536
+ // Desktop/WebRTC + outdial: accept disabled (auto-answer handles it; Widgets show "Accept" disabled)
537
+ // Extension mode (non-WebRTC): accept disabled (Widgets show "Ringing...")
538
+ accept: (() => {
539
+ if (isWxAppInboundOffer) {
540
+ return wxAppAcceptInFlight || wxAppAnswerPending ? VISIBLE_DISABLED : VISIBLE_ENABLED;
541
+ }
542
+ if (isWxAppOutdialOffer) {
543
+ return wxAppAcceptInFlight || wxAppAnswerPending ? VISIBLE_DISABLED : VISIBLE_ENABLED;
544
+ }
545
+ if (state === TaskState.OFFERED && !interaction?.isTerminated) {
546
+ return {isVisible: true, isEnabled: isWebrtc && !isOutdial};
547
+ }
548
+
549
+ return DISABLED;
550
+ })(),
551
+ decline: (() => {
552
+ if (wxAppAcceptInFlight) return VISIBLE_DISABLED;
553
+ if (wxAppAnswerPending && isWxAppInboundOffer) return VISIBLE_DISABLED;
554
+ if (isWxAppInboundOffer) return VISIBLE_ENABLED;
555
+ if (isWxAppOutdialOffer) return VISIBLE_ENABLED;
556
+ if (!isWebrtc || state !== TaskState.OFFERED || interaction?.isTerminated) return DISABLED;
557
+
558
+ return isOutdial ? VISIBLE_DISABLED : VISIBLE_ENABLED;
559
+ })(),
560
+
561
+ // Hold: visible in connected/held/conference, disabled in conference/consulting
562
+ hold: (() => {
563
+ if (!hasFullControls) return DISABLED;
564
+ if (isEpDnPostCallCustomerLeft) return VISIBLE_DISABLED;
565
+ if (forceHeldPostConsultControls) return VISIBLE_ENABLED;
566
+ if (
567
+ consultOwnedBySelf &&
568
+ (isConsulting || hasParallelConsultLeg || effectiveConsultCallHeld)
569
+ ) {
570
+ return DISABLED;
571
+ }
572
+ if (hasParallelConsultLeg) return DISABLED;
573
+ if (state === TaskState.OFFERED) return DISABLED;
574
+ if (isWrappingUp) return DISABLED;
575
+ // Visibility: connected || held || inConference
576
+ if (!(isConnected || isHeld || inConference)) return DISABLED;
577
+ // Enabled: (connected || held) && !inConference && !isConsulting
578
+ const canHold = (isConnected || isHeld) && !inConference && !isConsulting;
579
+
580
+ return canHold ? VISIBLE_ENABLED : VISIBLE_DISABLED;
581
+ })(),
582
+
583
+ // Mute: WebRTC or wxApp engaged calls; hidden entirely during wrapup
584
+ mute: (() => {
585
+ if (isWxAppEngaged && !isWrappingUp) {
586
+ return currentLeg === 'main' ? VISIBLE_ENABLED : DISABLED;
587
+ }
588
+ if (!isWebrtc) return DISABLED;
589
+ if (isWrappingUp) return DISABLED;
590
+ if (isEpDnPostCallCustomerLeft && inConference) return VISIBLE_DISABLED;
591
+ if (currentLeg === 'consult' && !selfInConsultCall) return DISABLED;
592
+ // The consulted agent has no separate consult leg; their main leg is the active call, so
593
+ // mute stays available while they consult (the heuristic below targets the initiator's
594
+ // inactive leg, not the consultee's only leg).
595
+ const isConsultedActiveMainLeg = isConsulted && currentLeg === 'main';
596
+ if (
597
+ (isConsulting || hasParallelConsultLeg) &&
598
+ !isCurrentLegActive &&
599
+ !isConsultedActiveMainLeg
600
+ ) {
601
+ return VISIBLE_DISABLED;
602
+ }
603
+ if (isConsulting) return VISIBLE_ENABLED;
604
+
605
+ if (isConnected || isHeld || isConferencing) {
606
+ if (inConference) return VISIBLE_ENABLED;
607
+
608
+ return isHeld ? VISIBLE_DISABLED : VISIBLE_ENABLED;
609
+ }
610
+
611
+ return DISABLED;
612
+ })(),
613
+
614
+ // End: varies by state; during consulting only on main leg (consult held)
615
+ end: (() => {
616
+ if (allowHeldMainLegControlsForNonInitiator) return VISIBLE_ENABLED;
617
+ if (showMainLegConferenceControlsDuringConsult) return VISIBLE_DISABLED;
618
+ if (isHydratedConferenceConsultPending && currentLeg === 'main') return VISIBLE_DISABLED;
619
+ if (!config.isEndTaskEnabled && !isWebrtc) return DISABLED;
620
+ if (hasParallelConsultLeg) {
621
+ return isConnected && isEpDnConsult ? VISIBLE_ENABLED : VISIBLE_DISABLED;
622
+ }
623
+
624
+ if (isConsulting) {
625
+ if (currentLeg === 'consult' && consultCallHeld) return DISABLED;
626
+
627
+ return consultInitiator && consultCallHeld ? VISIBLE_ENABLED : DISABLED;
628
+ }
629
+
630
+ if (inConference) {
631
+ if (isConsulted) return DISABLED;
632
+ if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
633
+
634
+ if (effectiveConsultInProgress) return VISIBLE_DISABLED;
635
+
636
+ return isWrappingUp ? VISIBLE_DISABLED : VISIBLE_ENABLED;
637
+ }
638
+ if (!hasFullControls) return DISABLED;
639
+ if (isActive) return isHeld || isWrappingUp ? VISIBLE_DISABLED : VISIBLE_ENABLED;
640
+
641
+ return DISABLED;
642
+ })(),
643
+
644
+ // Transfer: connected/held/conference
645
+ transfer: (() => {
646
+ if (isHydratedConferenceConsultPending) return VISIBLE_DISABLED;
647
+ if (isConferenceConsultTransferContext && currentLeg === 'main' && isCurrentLegActive) {
648
+ return DISABLED;
649
+ }
650
+ if (inConference && isConsulting && consultInitiator) return DISABLED;
651
+ if (hasParallelConsultLeg) {
652
+ if (!customerPresent) return DISABLED;
653
+ if (shouldHideBlindTransferForEpDnPendingConferenceMerge) return DISABLED;
654
+ if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
655
+ if (state === TaskState.HELD) return VISIBLE_DISABLED;
656
+ }
657
+ if (isConsulting) {
658
+ if (!consultInitiator) return DISABLED;
659
+ if (!customerPresent) return VISIBLE_DISABLED;
660
+ if (consultLegOnHold) return VISIBLE_DISABLED;
661
+
662
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
663
+ }
664
+ if (!hasFullControls) return DISABLED;
665
+ if (inConference) {
666
+ // Real conference (multiple agents): transfer is hidden
667
+ // Pending conference (only self agent): transfer remains available
668
+ // EP-DN consult merged before destination joins: hide blind transfer on main
669
+ // (Agent 2 may still be ringing on the consult leg — blind transfer causes stuck ringing)
670
+ if (shouldHideBlindTransferForEpDnPendingConferenceMerge) {
671
+ return DISABLED;
672
+ }
673
+
674
+ if (participantCount > 1) {
675
+ return DISABLED;
676
+ }
677
+
678
+ return customerPresent ? VISIBLE_ENABLED : DISABLED;
679
+ }
680
+ if (state === TaskState.CONNECTED || state === TaskState.HELD) return VISIBLE_ENABLED;
681
+
682
+ return DISABLED;
683
+ })(),
684
+
685
+ // Consult: connected/held/conference when conditions met
686
+ consult: (() => {
687
+ const isConnectedOrHeld = state === TaskState.CONNECTED || state === TaskState.HELD;
688
+
689
+ if (
690
+ isHeld &&
691
+ !inConference &&
692
+ !effectiveHasConsultMedia &&
693
+ selfParticipant?.consultState === 'consultCompleted' &&
694
+ !taskData?.isConsulted
695
+ ) {
696
+ const canRetryConsult = !maxParticipants && customerPresent && !otherAgentConsultInProgress;
697
+
698
+ return {isVisible: true, isEnabled: canRetryConsult};
699
+ }
700
+
701
+ if (inConference && nonOwnerPostConsultCompletedHeldMainLeg) return VISIBLE_DISABLED;
702
+ if (hasParallelConsultLeg) return DISABLED;
703
+ if (!hasFullControls || !(isConnectedOrHeld || inConference)) {
704
+ return DISABLED;
705
+ }
706
+
707
+ // In conference: behavior depends on whether it's a real multi-agent conference
708
+ if (inConference) {
709
+ // Pending conference (only self agent): consult disabled
710
+ if (participantCount <= 1) return VISIBLE_DISABLED;
711
+ // Real conference: consult enabled if conditions met
712
+ const canFromConference =
713
+ !maxParticipants &&
714
+ customerPresent &&
715
+ !effectiveConsultInProgress &&
716
+ !otherAgentConsultInProgress &&
717
+ !isConsulting;
718
+
719
+ return {isVisible: true, isEnabled: canFromConference};
720
+ }
721
+
722
+ // Enabled conditions for connected/held
723
+ const canFromConnected =
724
+ !maxParticipants && customerPresent && !effectiveConsultInProgress && !isConsulted;
725
+
726
+ return {isVisible: true, isEnabled: canFromConnected};
727
+ })(),
728
+
729
+ // ConsultTransfer: always hidden (use transfer button)
730
+ consultTransfer: DISABLED,
731
+
732
+ // EndConsult: during consulting
733
+ endConsult: (() => {
734
+ if (!isConsulting) return DISABLED;
735
+ if (isConsulted && isConferencing) return DISABLED;
736
+ if (!isConsulted && isConferencing && !(consultInitiator && conferenceFromBackend)) {
737
+ return DISABLED;
738
+ }
739
+
740
+ return {isVisible: true, isEnabled: consultInitiator || config.isEndConsultEnabled};
741
+ })(),
742
+
743
+ // Recording: connected/held/conference when backend exposes recording controls
744
+ recording: (() => {
745
+ if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED;
746
+ if (!hasFullControls || isConsulting) return DISABLED;
747
+ if (inConference) {
748
+ // Recording stays available in conference when backend exposes controls
749
+ // (including after EP-DN pending merge transitions to real conference — CAI-8329)
750
+ if (isEpDnPostCallCustomerLeft) return VISIBLE_DISABLED;
751
+
752
+ return VISIBLE_ENABLED;
753
+ }
754
+ if (hasParallelConsultLeg && !customerPresent) return DISABLED;
755
+ if (state === TaskState.CONNECTED || state === TaskState.HELD) {
756
+ return VISIBLE_ENABLED;
757
+ }
758
+
759
+ return DISABLED;
760
+ })(),
761
+
762
+ // Conference: during consulting, enabled on both legs when agent joined
763
+ // Label changes based on leg: "Conference" on main leg, "Merge" on consult leg
764
+ conference: (() => {
765
+ if (isHydratedConferenceConsultPending && currentLeg === 'main') return VISIBLE_DISABLED;
766
+ if (hasParallelConsultLeg) {
767
+ if (!customerPresent) return DISABLED;
768
+ if (state === TaskState.CONNECTED) {
769
+ return maxParticipants ? VISIBLE_DISABLED : VISIBLE_ENABLED;
770
+ }
771
+ if (state === TaskState.HELD) return VISIBLE_DISABLED;
772
+
773
+ return DISABLED;
774
+ }
775
+ if (!hasFullControls || !isConsulting) return DISABLED;
776
+ if (!consultInitiator) return DISABLED;
777
+ if (!customerPresent) return VISIBLE_DISABLED;
778
+ if (consultLegOnHold) return VISIBLE_DISABLED;
779
+
780
+ return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
781
+ })(),
782
+
783
+ // Wrapup: wrapping up state
784
+ wrapup: isWrappingUp ? VISIBLE_ENABLED : DISABLED,
785
+
786
+ // ExitConference: in conference with multiple agents in main call
787
+ exitConference: (() => {
788
+ if (hideExitConferenceWhileConsultPending) return DISABLED;
789
+ if (hideExitConferenceOnMainLegForEpDnConsultFromConference) return DISABLED;
790
+ if (allowHeldMainLegControlsForNonInitiator) return VISIBLE_ENABLED;
791
+ if (showMainLegConferenceControlsDuringConsult) return VISIBLE_DISABLED;
792
+ if (hideExitConferenceDuringActiveConsultFromConference) return DISABLED;
793
+ if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
794
+ if (isConsulted && !isConferencing) return DISABLED;
795
+ if (!inConference) return DISABLED;
796
+ if (isEpDnPendingConferenceMergeUi) return VISIBLE_ENABLED;
797
+ if (participantCount <= 1) return DISABLED;
798
+ if (effectiveConsultInProgress) return VISIBLE_DISABLED;
799
+ const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
800
+
801
+ return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
802
+ })(),
803
+
804
+ // TransferConference: in conference with active consult, owner consulting from conference
805
+ transferConference: (() => {
806
+ if (isConferenceConsultTransferContext && !isCurrentLegActive) return VISIBLE_DISABLED;
807
+ const consultLegTransferAvailable =
808
+ currentLeg === 'consult' && inConference && selfActiveConsultInitiator && hasConsultMedia;
809
+ const selfConsultingOnParticipantState = selfParticipant?.consultState === 'consulting';
810
+ const conferenceTransferAvailable =
811
+ consultLegTransferAvailable ||
812
+ (inConference &&
813
+ selfActiveConsultInitiator &&
814
+ hasConsultMedia &&
815
+ (isConsulting || effectiveConsultInProgress || selfConsultingOnParticipantState));
816
+ if (consultLegOnHold) return DISABLED;
817
+ if (hasParallelConsultLeg && !conferenceTransferAvailable) return DISABLED;
818
+ if (!conferenceTransferAvailable && (!inConference || !isConsulting)) return DISABLED;
819
+ if (!selfActiveConsultInitiator || isConsulted) return DISABLED;
820
+
821
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
822
+ })(),
823
+
824
+ // MergeToConference: mirrors conference control, enabled on both legs
825
+ mergeToConference: (() => {
826
+ if (isHydratedConferenceConsultPending && currentLeg === 'consult') return VISIBLE_DISABLED;
827
+ if (!isConsulting || !consultInitiator) return DISABLED;
828
+ if (!customerPresent) return VISIBLE_DISABLED;
829
+ if (consultLegOnHold) return VISIBLE_DISABLED;
830
+
831
+ return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
832
+ })(),
833
+
834
+ // Switch: visible only on the currently active leg
835
+ switch: (() => {
836
+ if (isHydratedConferenceConsultPending && currentLeg === 'consult') return VISIBLE_DISABLED;
837
+ if (!customerPresent && hasParallelConsultLeg) return DISABLED;
838
+ if (currentLeg === 'consult') {
839
+ if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED;
840
+ if (!customerPresent) return VISIBLE_DISABLED;
841
+
842
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
843
+ }
844
+
845
+ if (hasParallelConsultLeg && state === TaskState.CONNECTED) {
846
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
847
+ }
848
+
849
+ return DISABLED;
850
+ })(),
851
+
852
+ keypad: (() => {
853
+ if (isWxAppEngaged && !isWrappingUp) {
854
+ return currentLeg === 'main' ? VISIBLE_ENABLED : DISABLED;
855
+ }
856
+
857
+ return DISABLED;
858
+ })(),
859
+ };
860
+ }
861
+
862
+ function computeDigitalInteractionUIControls(
863
+ state: TaskState,
864
+ context: TaskContext,
865
+ fallbackTaskData?: TaskData
866
+ ): InteractionUIControls {
867
+ const taskData = context.taskData ?? fallbackTaskData ?? null;
868
+ const isTerminated = taskData?.interaction?.isTerminated ?? false;
869
+
870
+ const isConnected = state === TaskState.CONNECTED;
871
+ const isWrappingUp = state === TaskState.WRAPPING_UP;
872
+
873
+ return {
874
+ accept: state === TaskState.OFFERED ? VISIBLE_ENABLED : DISABLED,
875
+ decline: DISABLED,
876
+ hold: DISABLED,
877
+ mute: DISABLED,
878
+ end: isConnected && !isWrappingUp ? VISIBLE_ENABLED : DISABLED,
879
+ transfer: isConnected && !isWrappingUp ? VISIBLE_ENABLED : DISABLED,
880
+ consult: DISABLED,
881
+ consultTransfer: DISABLED,
882
+ endConsult: DISABLED,
883
+ recording: DISABLED,
884
+ conference: DISABLED,
885
+ wrapup: isTerminated || isWrappingUp ? VISIBLE_ENABLED : DISABLED,
886
+ exitConference: DISABLED,
887
+ transferConference: DISABLED,
888
+ mergeToConference: DISABLED,
889
+ switch: DISABLED,
890
+ keypad: DISABLED,
891
+ };
892
+ }
893
+
894
+ function getVoiceLegState(
895
+ currentState: TaskState,
896
+ context: TaskContext,
897
+ config: UIControlConfig,
898
+ fallbackTaskData?: TaskData
899
+ ): {hasConsultLeg: boolean; activeLeg: TaskUILeg; mainState: TaskState; consultState: TaskState} {
900
+ if (currentState === TaskState.WRAPPING_UP) {
901
+ return {
902
+ hasConsultLeg: false,
903
+ activeLeg: 'main',
904
+ mainState: currentState,
905
+ consultState: TaskState.CONSULTING,
906
+ };
907
+ }
908
+
909
+ const taskData = fallbackTaskData ?? context.taskData ?? null;
910
+ const interaction = taskData?.interaction;
911
+ const mainCallId = interaction?.mainInteractionId || taskData?.interactionId;
912
+ const selfAgentId = config.agentId ?? taskData?.agentId;
913
+
914
+ if (context.hideBlindTransferForEpDnPendingMerge && taskData) {
915
+ const conferenceFromBackend = getIsConferenceInProgress(taskData);
916
+ const participantCount =
917
+ interaction && mainCallId ? getConferenceParticipantsCount(interaction, mainCallId) : 0;
918
+
919
+ if (
920
+ participantCount <= 1 &&
921
+ (currentState === TaskState.CONFERENCING || conferenceFromBackend)
922
+ ) {
923
+ return {
924
+ hasConsultLeg: false,
925
+ activeLeg: 'main',
926
+ mainState: TaskState.CONFERENCING,
927
+ consultState: TaskState.CONSULTING,
928
+ };
929
+ }
930
+ }
931
+
932
+ const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
933
+ const hasConsultMedia = hasConsultMediaInInteraction(
934
+ interaction,
935
+ taskData?.consultMediaResourceId
936
+ );
937
+ const isSecondaryConsultRejectedWhileEpDnActive =
938
+ taskData?.type === 'AgentConsultFailed' && context.hideBlindTransferForEpDnPendingMerge;
939
+ const isSelfConsultCompleted =
940
+ selfParticipant?.consultState === 'consultCompleted' && taskData?.isConsulted === false;
941
+ const isConsultEndedForSelf =
942
+ taskData?.type === 'AgentConsultEnded' ||
943
+ (taskData?.type === 'AgentConsultFailed' && !isSecondaryConsultRejectedWhileEpDnActive) ||
944
+ isSelfConsultCompleted;
945
+ const consultInProgress = getIsConsultInProgressForConferenceControls(
946
+ interaction,
947
+ mainCallId,
948
+ selfAgentId
949
+ );
950
+ const isConsultingState =
951
+ currentState === TaskState.CONSULTING ||
952
+ currentState === TaskState.CONSULT_INITIATING ||
953
+ currentState === TaskState.CONF_INITIATING;
954
+ const consultMedia = Object.values(interaction?.media ?? {}).find(
955
+ (media: any) =>
956
+ media?.mediaResourceId === taskData?.consultMediaResourceId || media?.mType === 'consult'
957
+ ) as {participants?: string[]} | undefined;
958
+ const selfInConsultCall =
959
+ Boolean(selfAgentId) && Boolean(consultMedia?.participants?.includes(selfAgentId as string));
960
+ const selfActiveConsultInitiator = isSelfActiveConsultInitiator(
961
+ context.consultInitiator,
962
+ selfAgentId,
963
+ selfParticipant,
964
+ selfInConsultCall,
965
+ taskData?.consultingAgentId
966
+ );
967
+ const consultOwnedBySelf =
968
+ !context.hideBlindTransferForEpDnPendingMerge && selfActiveConsultInitiator;
969
+ const selfConsultPendingOnConsultMedia =
970
+ selfParticipant?.consultState === 'consultInitiated' &&
971
+ !taskData?.isConsulted &&
972
+ hasConsultMedia;
973
+ const selfConsultingOnConsultMedia =
974
+ selfParticipant?.consultState === 'consulting' && hasConsultMedia;
975
+ const isConsultUnansweredFailure =
976
+ isSelfConsultCompleted &&
977
+ Boolean(mainCallId && taskData?.interaction?.media?.[mainCallId]?.isHold === true) &&
978
+ taskData?.interaction?.state !== 'conference' &&
979
+ !getIsConferenceInProgress(taskData);
980
+ const hasConsultLeg = Boolean(
981
+ !isConsultEndedForSelf &&
982
+ !interaction?.isTerminated &&
983
+ !isConsultUnansweredFailure &&
984
+ (consultOwnedBySelf || selfConsultingOnConsultMedia || selfConsultPendingOnConsultMedia) &&
985
+ (consultInProgress || isConsultingState || context.consultCallHeld || hasConsultMedia)
986
+ );
987
+
988
+ if (!hasConsultLeg) {
989
+ let mainState = currentState;
990
+ if (isConsultEndedForSelf && taskData?.interaction) {
991
+ const resolvedMainId = taskData.interaction.mainInteractionId || taskData.interactionId;
992
+ const isMainHeld = Boolean(
993
+ resolvedMainId && taskData.interaction.media?.[resolvedMainId]?.isHold === true
994
+ );
995
+
996
+ if (taskData.interaction.state === 'conference' || getIsConferenceInProgress(taskData)) {
997
+ mainState = TaskState.CONFERENCING;
998
+ } else if (isMainHeld || taskData.interaction.state === 'hold') {
999
+ mainState = TaskState.HELD;
1000
+ } else {
1001
+ mainState = TaskState.CONNECTED;
1002
+ }
1003
+ }
1004
+
1005
+ return {
1006
+ hasConsultLeg: false,
1007
+ activeLeg: 'main',
1008
+ mainState,
1009
+ consultState: TaskState.CONSULTING,
1010
+ };
1011
+ }
1012
+
1013
+ let mainState = TaskState.HELD;
1014
+ if (currentState === TaskState.CONFERENCING) {
1015
+ mainState = TaskState.CONFERENCING;
1016
+ } else if (context.consultCallHeld) {
1017
+ const mainMediaId = taskData?.interaction?.mainInteractionId || taskData?.interactionId;
1018
+ const isMainHeld = Boolean(
1019
+ mainMediaId && taskData?.interaction?.media?.[mainMediaId]?.isHold === true
1020
+ );
1021
+ mainState =
1022
+ isMainHeld || taskData?.interaction?.state === 'hold' ? TaskState.HELD : TaskState.CONNECTED;
1023
+ } else if (currentState === TaskState.CONNECTED || currentState === TaskState.HELD) {
1024
+ mainState = currentState;
1025
+ }
1026
+
1027
+ return {
1028
+ hasConsultLeg: true,
1029
+ activeLeg: context.consultCallHeld ? 'main' : 'consult',
1030
+ mainState,
1031
+ consultState: isConsultingState ? currentState : TaskState.CONSULTING,
1032
+ };
1033
+ }
1034
+
1035
+ export function computeUIControls(
1036
+ currentState: TaskState,
1037
+ context: TaskContext,
1038
+ fallbackTaskData?: TaskData
1039
+ ): TaskUIControls {
1040
+ if (currentState === TaskState.TERMINATED || currentState === TaskState.COMPLETED) {
1041
+ return getDefaultUIControls();
1042
+ }
1043
+
1044
+ const consultTransferDestinations = getConsultTransferDestinations(
1045
+ context.uiControlConfig,
1046
+ fallbackTaskData ?? context.taskData
1047
+ );
1048
+
1049
+ switch (context.uiControlConfig.channelType) {
1050
+ case TASK_CHANNEL_TYPE.VOICE: {
1051
+ const {hasConsultLeg, activeLeg, mainState, consultState} = getVoiceLegState(
1052
+ currentState,
1053
+ context,
1054
+ context.uiControlConfig,
1055
+ fallbackTaskData
1056
+ );
1057
+
1058
+ const mainControls = computeVoiceInteractionUIControls(
1059
+ mainState,
1060
+ context,
1061
+ context.uiControlConfig,
1062
+ fallbackTaskData,
1063
+ 'main'
1064
+ );
1065
+ const consultControls = hasConsultLeg
1066
+ ? computeVoiceInteractionUIControls(
1067
+ consultState,
1068
+ context,
1069
+ context.uiControlConfig,
1070
+ fallbackTaskData,
1071
+ 'consult'
1072
+ )
1073
+ : getDefaultInteractionUIControls();
1074
+
1075
+ return createTaskUIControls(
1076
+ mainControls,
1077
+ consultControls,
1078
+ activeLeg,
1079
+ consultTransferDestinations
1080
+ );
1081
+ }
1082
+ case TASK_CHANNEL_TYPE.DIGITAL:
1083
+ return createTaskUIControls(
1084
+ computeDigitalInteractionUIControls(currentState, context, fallbackTaskData),
1085
+ getDefaultInteractionUIControls(),
1086
+ 'main',
1087
+ consultTransferDestinations
1088
+ );
1089
+ default:
1090
+ return getDefaultUIControls();
1091
+ }
1092
+ }
1093
+
1094
+ function haveInteractionUIControlsChanged(
1095
+ previous: InteractionUIControls,
1096
+ next: InteractionUIControls
1097
+ ): boolean {
1098
+ return (Object.keys(next) as (keyof InteractionUIControls)[]).some((key) => {
1099
+ const prev = previous[key];
1100
+ const curr = next[key];
1101
+
1102
+ return prev.isVisible !== curr.isVisible || prev.isEnabled !== curr.isEnabled;
1103
+ });
1104
+ }
1105
+
1106
+ function haveDestinationListChanged(
1107
+ previous: DestinationControls['consult'],
1108
+ next: DestinationControls['consult']
1109
+ ): boolean {
1110
+ return (
1111
+ previous.length !== next.length ||
1112
+ previous.some((destination, index) => destination !== next[index])
1113
+ );
1114
+ }
1115
+
1116
+ export function haveUIControlsChanged(
1117
+ previous: TaskUIControls | undefined,
1118
+ next: TaskUIControls
1119
+ ): boolean {
1120
+ if (!previous) return true;
1121
+
1122
+ return (
1123
+ previous.activeLeg !== next.activeLeg ||
1124
+ haveDestinationListChanged(
1125
+ previous.consultTransferDestinations.consult,
1126
+ next.consultTransferDestinations.consult
1127
+ ) ||
1128
+ haveDestinationListChanged(
1129
+ previous.consultTransferDestinations.transfer,
1130
+ next.consultTransferDestinations.transfer
1131
+ ) ||
1132
+ haveInteractionUIControlsChanged(previous.main, next.main) ||
1133
+ haveInteractionUIControlsChanged(previous.consult, next.consult)
1134
+ );
1135
+ }