@nyaruka/temba-components 0.135.9 → 0.136.1

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 (144) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/demo/components/webchat/example.html +4 -2
  3. package/dist/static/svg/index.svg +1 -1
  4. package/dist/temba-components.js +1351 -322
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/Icons.js +2 -1
  7. package/out-tsc/src/Icons.js.map +1 -1
  8. package/out-tsc/src/display/FloatingTab.js +2 -6
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/flow/CanvasNode.js +29 -1
  11. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  12. package/out-tsc/src/flow/Editor.js +229 -5
  13. package/out-tsc/src/flow/Editor.js.map +1 -1
  14. package/out-tsc/src/flow/Plumber.js +320 -1
  15. package/out-tsc/src/flow/Plumber.js.map +1 -1
  16. package/out-tsc/src/interfaces.js +1 -0
  17. package/out-tsc/src/interfaces.js.map +1 -1
  18. package/out-tsc/src/layout/FloatingWindow.js +30 -8
  19. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  20. package/out-tsc/src/simulator/Simulator.js +1861 -0
  21. package/out-tsc/src/simulator/Simulator.js.map +1 -0
  22. package/out-tsc/src/store/AppState.js +66 -0
  23. package/out-tsc/src/store/AppState.js.map +1 -1
  24. package/out-tsc/src/utils.js +48 -0
  25. package/out-tsc/src/utils.js.map +1 -1
  26. package/out-tsc/temba-modules.js +2 -0
  27. package/out-tsc/temba-modules.js.map +1 -1
  28. package/out-tsc/test/temba-appstate-node-sorting.test.js +430 -0
  29. package/out-tsc/test/temba-appstate-node-sorting.test.js.map +1 -0
  30. package/out-tsc/test/temba-floating-tab.test.js +0 -9
  31. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  32. package/out-tsc/test/temba-flow-editor.test.js +262 -1
  33. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  34. package/out-tsc/test/temba-flow-plumber-connections.test.js +3 -1
  35. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  36. package/out-tsc/test/temba-flow-plumber.test.js +3 -1
  37. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  38. package/out-tsc/test/temba-simulator.test.js +642 -0
  39. package/out-tsc/test/temba-simulator.test.js.map +1 -0
  40. package/out-tsc/test/utils.test.js +1 -1
  41. package/out-tsc/test/utils.test.js.map +1 -1
  42. package/package.json +1 -1
  43. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  44. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  45. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  46. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  47. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  50. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  51. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  52. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  53. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  54. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  55. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  56. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  57. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  58. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  59. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  60. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  61. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  62. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  63. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  64. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  65. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  66. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  67. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  68. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  69. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  70. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  71. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  72. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  73. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  74. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  75. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  76. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  77. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  78. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  79. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  80. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  81. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  83. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  84. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  85. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  86. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  87. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  88. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  89. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  90. package/screenshots/truth/floating-tab/gray.png +0 -0
  91. package/screenshots/truth/floating-tab/green.png +0 -0
  92. package/screenshots/truth/floating-tab/purple.png +0 -0
  93. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  94. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  95. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  96. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  97. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  98. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  99. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  103. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  104. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  105. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  106. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  112. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  113. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  114. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  115. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  116. package/screenshots/truth/simulator/after-reset.png +0 -0
  117. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  118. package/screenshots/truth/simulator/context-expanded.png +0 -0
  119. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  120. package/screenshots/truth/simulator/event-info.png +0 -0
  121. package/screenshots/truth/simulator/image-attachment.png +0 -0
  122. package/screenshots/truth/simulator/open-initial.png +0 -0
  123. package/screenshots/truth/simulator/quick-replies.png +0 -0
  124. package/src/Icons.ts +2 -1
  125. package/src/display/FloatingTab.ts +2 -7
  126. package/src/flow/CanvasNode.ts +30 -1
  127. package/src/flow/Editor.ts +246 -4
  128. package/src/flow/Plumber.ts +371 -2
  129. package/src/interfaces.ts +2 -1
  130. package/src/layout/FloatingWindow.ts +37 -12
  131. package/src/simulator/Simulator.ts +2061 -0
  132. package/src/store/AppState.ts +109 -0
  133. package/src/utils.ts +53 -0
  134. package/static/svg/index.svg +1 -1
  135. package/static/svg/work/traced/route.svg +1 -0
  136. package/static/svg/work/used/route.svg +3 -0
  137. package/temba-modules.ts +2 -0
  138. package/test/temba-appstate-node-sorting.test.ts +506 -0
  139. package/test/temba-floating-tab.test.ts +0 -11
  140. package/test/temba-flow-editor.test.ts +298 -1
  141. package/test/temba-flow-plumber-connections.test.ts +4 -1
  142. package/test/temba-flow-plumber.test.ts +4 -1
  143. package/test/temba-simulator.test.ts +866 -0
  144. package/test/utils.test.ts +1 -1
@@ -0,0 +1,2061 @@
1
+ import { html, TemplateResult } from 'lit-html';
2
+ import { RapidElement } from '../RapidElement';
3
+ import { FloatingWindow } from '../layout/FloatingWindow';
4
+ import { css, PropertyValueMap } from 'lit';
5
+ import { property } from 'lit/decorators.js';
6
+ import { postJSON, fromCookie } from '../utils';
7
+ import { getStore } from '../store/Store';
8
+ import { CustomEventType } from '../interfaces';
9
+
10
+ // test attachment URLs
11
+ const TEST_IMAGES = [
12
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_a.jpg',
13
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_b.jpg',
14
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_c.jpg',
15
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_d.jpg'
16
+ ];
17
+
18
+ const TEST_VIDEOS = [
19
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_video_a.mp4'
20
+ ];
21
+
22
+ const TEST_AUDIO = [
23
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_audio_a.mp3'
24
+ ];
25
+
26
+ const TEST_LOCATIONS = [
27
+ 'geo:47.6062,-122.3321', // Seattle
28
+ 'geo:-0.1807,-78.4678', // Quito
29
+ 'geo:-2.9001,-79.0059', // Cuenca
30
+ 'geo:-1.9536,30.0606' // Kigali
31
+ ];
32
+
33
+ interface Contact {
34
+ uuid: string;
35
+ name?: string;
36
+ urns: string[];
37
+ fields?: { [key: string]: any };
38
+ groups?: any[];
39
+ language?: string;
40
+ status?: string;
41
+ created_on?: string;
42
+ }
43
+
44
+ interface Session {
45
+ environment: any;
46
+ runs: any[];
47
+ status: string;
48
+ trigger: any;
49
+ wait?: any;
50
+ }
51
+
52
+ interface Message {
53
+ uuid: string;
54
+ text?: string;
55
+ urn: string;
56
+ attachments?: string[];
57
+ quick_replies?: any[];
58
+ }
59
+
60
+ interface Event {
61
+ type: string;
62
+ created_on: string;
63
+ msg?: Message;
64
+ [key: string]: any;
65
+ }
66
+
67
+ interface RunContext {
68
+ session: Session;
69
+ events: Event[];
70
+ context?: any;
71
+ contact?: Contact;
72
+ }
73
+
74
+ interface SimulatorSize {
75
+ phoneWidth: number;
76
+ phoneHeight: number;
77
+ phoneTotalHeight: number;
78
+ phoneScreenHeight: number;
79
+ contextWidth: number;
80
+ contextHeight: number;
81
+ contextOffset: number;
82
+ optionPaneWidth: number;
83
+ optionPaneGap: number;
84
+ windowPadding: number;
85
+ cutoutHeight: number;
86
+ cutoutPadding: number;
87
+ cutoutFontSize: number;
88
+ cutoutIslandWidth: number;
89
+ cutoutIslandHeight: number;
90
+ cutoutIslandTop: number;
91
+ }
92
+
93
+ const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
94
+ small: {
95
+ phoneWidth: 270,
96
+ phoneHeight: 576,
97
+ phoneTotalHeight: 576,
98
+ phoneScreenHeight: 376,
99
+ contextWidth: 336,
100
+ contextHeight: 416,
101
+ contextOffset: 48,
102
+ optionPaneWidth: 44,
103
+ optionPaneGap: 10,
104
+ windowPadding: 24,
105
+ cutoutHeight: 32,
106
+ cutoutPadding: 12,
107
+ cutoutFontSize: 10,
108
+ cutoutIslandWidth: 80,
109
+ cutoutIslandHeight: 20,
110
+ cutoutIslandTop: 6
111
+ },
112
+ medium: {
113
+ phoneWidth: 300,
114
+ phoneHeight: 720,
115
+ phoneTotalHeight: 720,
116
+ phoneScreenHeight: 470,
117
+ contextWidth: 420,
118
+ contextHeight: 520,
119
+ contextOffset: 60,
120
+ optionPaneWidth: 44,
121
+ optionPaneGap: 12,
122
+ windowPadding: 30,
123
+ cutoutHeight: 40,
124
+ cutoutPadding: 16,
125
+ cutoutFontSize: 12,
126
+ cutoutIslandWidth: 100,
127
+ cutoutIslandHeight: 24,
128
+ cutoutIslandTop: 8
129
+ },
130
+ large: {
131
+ phoneWidth: 360,
132
+ phoneHeight: 864,
133
+ phoneTotalHeight: 864,
134
+ phoneScreenHeight: 564,
135
+ contextWidth: 504,
136
+ contextHeight: 624,
137
+ contextOffset: 72,
138
+ optionPaneWidth: 44,
139
+ optionPaneGap: 14,
140
+ windowPadding: 36,
141
+ cutoutHeight: 50,
142
+ cutoutPadding: 20,
143
+ cutoutFontSize: 14,
144
+ cutoutIslandWidth: 120,
145
+ cutoutIslandHeight: 30,
146
+ cutoutIslandTop: 10
147
+ }
148
+ };
149
+
150
+ export class Simulator extends RapidElement {
151
+ static get styles() {
152
+ return css`
153
+ :host {
154
+ /* size-specific dimensions are set dynamically via inline styles */
155
+ --phone-width: 300px;
156
+ --phone-total-height: 720px;
157
+ --context-width: 420px;
158
+ --context-offset: 60px;
159
+ --option-pane-width: 44px;
160
+ --option-pane-gap: 12px;
161
+ --window-padding: 30px;
162
+ --phone-screen-height: 470px;
163
+ --context-height: 520px;
164
+ --context-closed-left: 332px;
165
+ --animation-time: 200ms;
166
+ }
167
+
168
+ .phone-simulator {
169
+ padding-left: calc(var(--context-width) + var(--context-offset));
170
+ padding-top: var(--window-padding);
171
+ padding-bottom: var(--window-padding);
172
+ position: relative;
173
+ display: flex;
174
+ align-items: flex-start;
175
+ }
176
+
177
+ .option-pane {
178
+ margin-top: var(--window-padding);
179
+ margin-left: var(--option-pane-gap);
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: 6px;
183
+ padding: 6px;
184
+ background: rgba(0, 0, 0, 0.7);
185
+ backdrop-filter: blur(10px);
186
+ border-radius: 16px;
187
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
188
+ pointer-events: all;
189
+ }
190
+ .option-btn {
191
+ background: rgba(255, 255, 255, 0.1);
192
+ border: none;
193
+ border-radius: 12px;
194
+ width: 32px;
195
+ height: 32px;
196
+ display: flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ cursor: pointer;
200
+ transition: all var(--animation-time) ease;
201
+ color: white;
202
+ }
203
+ .option-btn:hover {
204
+ background: rgba(255, 255, 255, 0.2);
205
+ transform: scale(1.05);
206
+ }
207
+ .option-btn:active {
208
+ transform: scale(0.95);
209
+ }
210
+ .option-btn.active {
211
+ background: var(--color-primary-dark);
212
+ color: white;
213
+ }
214
+ .option-btn.active:hover {
215
+ background: var(--color-primary-dark);
216
+ }
217
+
218
+ .phone-frame {
219
+ width: var(--phone-width);
220
+ border-radius: 40px;
221
+ border: 6px solid #1f2937;
222
+ box-shadow: 0 0px 30px rgba(0, 0, 0, 0.4);
223
+ background: #000;
224
+ position: relative;
225
+ overflow: hidden;
226
+ z-index: 2;
227
+ }
228
+
229
+ .context-explorer {
230
+ width: var(--context-width);
231
+ height: var(--context-height);
232
+ border-top-left-radius: 16px;
233
+ border-bottom-left-radius: 16px;
234
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
235
+ position: absolute;
236
+ left: var(--context-closed-left);
237
+ top: calc(var(--window-padding) + 40px);
238
+ z-index: 1;
239
+ font-size: 13px;
240
+ color: #374151;
241
+ transition: left calc(var(--animation-time) * 1.5) ease-out,
242
+ opacity calc(var(--animation-time) * 1.5) ease-out;
243
+ opacity: 0;
244
+ pointer-events: none;
245
+ background: rgba(0, 0, 0, 0.7);
246
+ backdrop-filter: blur(10px);
247
+ display: flex;
248
+ flex-direction: column;
249
+ padding: 12px;
250
+ }
251
+
252
+ .context-gutter {
253
+ background: rgba(0, 0, 0, 0.3);
254
+ border-radius: 6px;
255
+
256
+ display: flex;
257
+ flex-direction: row;
258
+ align-items: center;
259
+ padding: 4px;
260
+ margin-right: 32px;
261
+ margin-top: 8px;
262
+ flex-shrink: 0;
263
+ }
264
+
265
+ .context-gutter-btn {
266
+ width: 14px;
267
+ height: 14px;
268
+ display: flex;
269
+ align-items: center;
270
+ justify-content: center;
271
+ cursor: pointer;
272
+ border-radius: 6px;
273
+ transition: background var(--animation-time) ease;
274
+ color: rgba(255, 255, 255, 0.6);
275
+ padding: 4px;
276
+ }
277
+
278
+ .context-gutter-btn:hover {
279
+ background: rgba(255, 255, 255, 0.1);
280
+ color: rgba(255, 255, 255, 0.9);
281
+ }
282
+
283
+ .context-gutter-btn.active {
284
+ color: #c084fc;
285
+ }
286
+
287
+ .context-gutter-spacer {
288
+ flex: 1;
289
+ }
290
+
291
+ .context-explorer-scroll {
292
+ scrollbar-color: rgba(255, 255, 255, 0.3) #4a4a4a;
293
+ scrollbar-width: thin;
294
+ height: 100%;
295
+ overflow-y: scroll;
296
+ padding-right: 10px;
297
+ margin-right: 30px;
298
+ flex-grow: 1;
299
+ }
300
+
301
+ .context-explorer-bleed {
302
+ height: 100%;
303
+ width: 0px;
304
+ }
305
+
306
+ .context-explorer-scroll::-webkit-scrollbar {
307
+ width: 18px;
308
+ }
309
+
310
+ .context-explorer-scroll::-webkit-scrollbar-track {
311
+ background: rgba(0, 0, 0, 0.3);
312
+ border-radius: 4px;
313
+ }
314
+
315
+ .context-explorer-scroll::-webkit-scrollbar-thumb {
316
+ background: rgba(255, 255, 255, 0.3);
317
+ border-radius: 4px;
318
+ }
319
+
320
+ .context-explorer-scroll::-webkit-scrollbar-thumb:hover {
321
+ background: rgba(255, 255, 255, 0.5);
322
+ }
323
+
324
+ .context-explorer.open {
325
+ left: var(--context-offset);
326
+ opacity: 1;
327
+ pointer-events: auto;
328
+ }
329
+
330
+ .context-item {
331
+ display: flex;
332
+ align-items: flex-start;
333
+ padding: 2px 4px;
334
+ cursor: pointer;
335
+ user-select: none;
336
+ }
337
+
338
+ .context-item:hover {
339
+ background: rgba(0, 0, 0, 0.05);
340
+ }
341
+
342
+ .context-item-expandable {
343
+ display: flex;
344
+ align-items: center;
345
+ }
346
+
347
+ .context-expand-icon {
348
+ width: 16px;
349
+ display: inline-block;
350
+ text-align: center;
351
+ flex-shrink: 0;
352
+ transition: transform var(--animation-time) ease;
353
+ color: #ffffff;
354
+ }
355
+
356
+ .context-expand-icon.expanded {
357
+ transform: rotate(90deg);
358
+ }
359
+
360
+ .context-key {
361
+ color: #ffffff;
362
+ flex-shrink: 0;
363
+ margin-right: 8px;
364
+ display: flex;
365
+ }
366
+
367
+ .context-key.has-value {
368
+ color: #e8b5e8;
369
+ }
370
+
371
+ .context-value {
372
+ color: #aaa;
373
+ flex: 1;
374
+ text-align: right;
375
+ overflow: hidden;
376
+ text-overflow: ellipsis;
377
+ white-space: nowrap;
378
+ }
379
+
380
+ .context-children {
381
+ margin-left: 16px;
382
+ }
383
+
384
+ .context-copy-icon {
385
+ opacity: 0;
386
+ margin-left: 4px;
387
+ transition: opacity var(--animation-time) ease;
388
+ cursor: pointer;
389
+ color: #ccc;
390
+ }
391
+
392
+ .context-item:hover .context-copy-icon {
393
+ opacity: 1;
394
+ }
395
+
396
+ .context-toast {
397
+ position: absolute;
398
+ bottom: 60px;
399
+ left: 50%;
400
+ transform: translateX(-50%);
401
+ background: #666;
402
+ color: white;
403
+ padding: 12px 12px;
404
+ border-radius: 8px;
405
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
406
+ font-size: 13px;
407
+ z-index: 10;
408
+ animation: slideInUp var(--animation-time) ease-out;
409
+ }
410
+
411
+ .context-toast .expression {
412
+ color: #e8b5e8;
413
+ font-weight: 600;
414
+ }
415
+
416
+ @keyframes slideInUp {
417
+ from {
418
+ opacity: 0;
419
+ transform: translateX(-50%) translateY(20px);
420
+ }
421
+ to {
422
+ opacity: 1;
423
+ transform: translateX(-50%) translateY(0);
424
+ }
425
+ }
426
+
427
+ .phone-top {
428
+ position: absolute;
429
+ top: 0;
430
+ left: 0;
431
+ right: 0;
432
+ z-index: 10;
433
+ cursor: grab;
434
+ }
435
+ .phone-notch {
436
+ background: transparent;
437
+ height: var(--cutout-height);
438
+ position: relative;
439
+ display: flex;
440
+ align-items: center;
441
+ justify-content: center;
442
+ padding: 0 var(--cutout-padding);
443
+ }
444
+ .phone-notch::before {
445
+ content: '';
446
+ position: absolute;
447
+ top: 0;
448
+ left: 0;
449
+ right: 0;
450
+ height: 100%;
451
+ background: linear-gradient(
452
+ to bottom,
453
+ rgba(0, 0, 0, 0.3) 0%,
454
+ rgba(0, 0, 0, 0.2) 50%,
455
+ transparent 100%
456
+ );
457
+ z-index: -1;
458
+ }
459
+ .dynamic-island {
460
+ top: var(--cutout-island-top);
461
+ left: 50%;
462
+
463
+ width: var(--cutout-island-width);
464
+ height: var(--cutout-island-height);
465
+ background: #000;
466
+ border-radius: calc(var(--cutout-island-height) / 1.5);
467
+ z-index: 1;
468
+ }
469
+ .phone-notch .time {
470
+ color: #000;
471
+ font-size: var(--cutout-font-size);
472
+ font-weight: 600;
473
+ }
474
+ .phone-notch .status-icons {
475
+ display: flex;
476
+ gap: 4px;
477
+ align-items: center;
478
+ }
479
+ .phone-notch .status-icons span {
480
+ color: #000;
481
+ font-size: var(--cutout-font-size);
482
+ }
483
+ .phone-header {
484
+ background: transparent;
485
+ padding: 10px 15px;
486
+ display: flex;
487
+ align-items: center;
488
+ justify-content: flex-end;
489
+ cursor: move;
490
+ user-select: none;
491
+ border-bottom: none;
492
+ pointer-events: all;
493
+ }
494
+
495
+ .phone-screen {
496
+ background: white;
497
+ padding: 15px;
498
+ padding-top: calc(var(--cutout-height) + 10px);
499
+ padding-bottom: 60px;
500
+ height: var(--phone-screen-height);
501
+ overflow-y: scroll;
502
+ display: flex;
503
+ flex-direction: column;
504
+ scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
505
+ scrollbar-width: thin;
506
+ }
507
+
508
+ .phone-screen::-webkit-scrollbar {
509
+ width: 8px;
510
+ }
511
+
512
+ .phone-screen::-webkit-scrollbar-track {
513
+ background: transparent;
514
+ }
515
+
516
+ .phone-screen::-webkit-scrollbar-thumb {
517
+ background: rgba(0, 0, 0, 0.2);
518
+ border-radius: 4px;
519
+ }
520
+
521
+ .phone-screen::-webkit-scrollbar-thumb:hover {
522
+ background: rgba(0, 0, 0, 0.3);
523
+ }
524
+
525
+ @keyframes messageAppear {
526
+ 0% {
527
+ opacity: 0;
528
+ transform: scale(0.8);
529
+ }
530
+ 70% {
531
+ opacity: 1;
532
+ transform: scale(1.05);
533
+ }
534
+ 100% {
535
+ opacity: 1;
536
+ transform: scale(1);
537
+ }
538
+ }
539
+
540
+ .message {
541
+ padding: 10px 14px;
542
+ margin-bottom: 8px;
543
+ border-radius: 18px;
544
+ max-width: 70%;
545
+ font-size: 13px;
546
+ line-height: 1.2;
547
+ }
548
+ .message.animated {
549
+ animation: messageAppear var(--animation-time) ease-out forwards;
550
+ opacity: 0;
551
+ }
552
+ .message.incoming {
553
+ background: #e5e5ea;
554
+ color: #000;
555
+ margin-right: auto;
556
+ border-bottom-left-radius: 4px;
557
+ }
558
+ .message.outgoing {
559
+ background: #007aff;
560
+ color: white;
561
+ margin-left: auto;
562
+ text-align: left;
563
+ border-bottom-right-radius: 4px;
564
+ }
565
+ .attachment-wrapper {
566
+ max-width: 70%;
567
+ margin-bottom: 8px;
568
+ display: flex;
569
+ flex-direction: column;
570
+ gap: 4px;
571
+ }
572
+ .attachment-wrapper.incoming {
573
+ margin-right: auto;
574
+ align-items: flex-start;
575
+ }
576
+ .attachment-wrapper.outgoing {
577
+ margin-left: auto;
578
+ align-items: flex-end;
579
+ }
580
+ .attachment-wrapper.animated {
581
+ animation: messageAppear var(--animation-time) ease-out forwards;
582
+ opacity: 0;
583
+ }
584
+ .attachment {
585
+ border-radius: 12px;
586
+ overflow: hidden;
587
+ max-width: 100%;
588
+ }
589
+ .attachment img {
590
+ max-width: 100%;
591
+ display: block;
592
+ border-radius: 12px;
593
+ }
594
+ .attachment video {
595
+ max-width: 100%;
596
+ display: block;
597
+ border-radius: 12px;
598
+ }
599
+ .attachment-audio {
600
+ display: flex;
601
+ align-items: center;
602
+ gap: 8px;
603
+ padding: 6px;
604
+ background: white;
605
+ border: 1px solid #e5e5ea;
606
+ border-radius: 12px;
607
+ min-width: 160px;
608
+ }
609
+ .attachment-wrapper.outgoing .attachment-audio {
610
+ background: white;
611
+ border: none;
612
+ }
613
+ .attachment-audio audio {
614
+ flex: 1;
615
+ max-height: 30px;
616
+ }
617
+ .attachment-location {
618
+ border-radius: 12px;
619
+ overflow: hidden;
620
+ }
621
+ .event-info {
622
+ text-align: center;
623
+ font-size: 11px;
624
+ color: #8e8e93;
625
+ margin: 4px 0;
626
+ padding: 0 10px;
627
+ line-height: 1.3;
628
+ }
629
+ .event-info.animated {
630
+ animation: messageAppear var(--animation-time) ease-out forwards;
631
+ opacity: 0;
632
+ }
633
+ .message-input {
634
+ background: linear-gradient(
635
+ to top,
636
+ rgba(0, 0, 0, 0.1) 0%,
637
+ rgba(0, 0, 0, 0.05) 70%,
638
+ transparent 100%
639
+ );
640
+ padding: 8px 12px;
641
+ border-top: none;
642
+ display: flex;
643
+ align-items: center;
644
+ gap: 8px;
645
+ position: absolute;
646
+ bottom: 0px;
647
+ left: 0px;
648
+ right: 0px;
649
+ z-index: 10;
650
+ }
651
+ .message-input input {
652
+ flex: 1;
653
+ border: 1px solid #c6c6c8;
654
+ border-radius: 20px;
655
+ padding: 8px 15px;
656
+ font-size: 15px;
657
+ margin-bottom: 5px;
658
+ background: white;
659
+ border: none;
660
+ outline: none;
661
+ }
662
+ .message-input input::placeholder {
663
+ color: #8e8e93;
664
+ }
665
+ .attachment-button {
666
+ width: 30px;
667
+ height: 30px;
668
+ border-radius: 50%;
669
+ background: #fff;
670
+ border: none;
671
+ display: flex;
672
+ align-items: center;
673
+ justify-content: center;
674
+ cursor: pointer;
675
+ flex-shrink: 0;
676
+ margin-bottom: 5px;
677
+ transition: all var(--animation-time) ease;
678
+ color: #000;
679
+ }
680
+ .attachment-button:hover {
681
+ background: #f8f8f8ff;
682
+ transform: scale(1.05);
683
+ }
684
+ .attachment-button:active {
685
+ transform: scale(0.95);
686
+ }
687
+ .attachment-menu {
688
+ position: absolute;
689
+ bottom: 55px;
690
+ left: 12px;
691
+ background: white;
692
+ border-radius: 12px;
693
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
694
+ padding: 8px;
695
+ display: flex;
696
+ flex-direction: column;
697
+ gap: 4px;
698
+ opacity: 0;
699
+ pointer-events: none;
700
+ transform: translateY(10px);
701
+ transition: opacity var(--animation-time) ease, transform 0.2s ease;
702
+ z-index: 20;
703
+ }
704
+ .attachment-menu.open {
705
+ opacity: 1;
706
+ pointer-events: all;
707
+ transform: translateY(0);
708
+ }
709
+ .attachment-menu-item {
710
+ display: flex;
711
+ align-items: center;
712
+ gap: 8px;
713
+ padding: 8px 12px;
714
+ border-radius: 8px;
715
+ cursor: pointer;
716
+ transition: background var(--animation-time) ease;
717
+ white-space: nowrap;
718
+ font-size: 14px;
719
+ color: #1f2937;
720
+ }
721
+ .attachment-menu-item:hover {
722
+ background: #f3f4f6;
723
+ }
724
+ .attachment-menu-item temba-icon {
725
+ color: #007aff;
726
+ }
727
+ .quick-replies {
728
+ display: flex;
729
+ flex-wrap: wrap;
730
+ justify-content: center;
731
+ gap: 8px;
732
+ margin-top: 4px;
733
+ margin-bottom: 8px;
734
+ }
735
+ .quick-reply-btn {
736
+ background: white;
737
+ color: #007aff;
738
+ border: 1px solid #007aff;
739
+ border-radius: 18px;
740
+ padding: 4px 8px;
741
+ font-size: 11px;
742
+ cursor: pointer;
743
+ transition: all var(--animation-time) ease;
744
+ white-space: nowrap;
745
+ }
746
+ .quick-reply-btn:hover {
747
+ background: #007aff;
748
+ color: white;
749
+ cursor: pointer;
750
+ }
751
+ .quick-reply-btn:active {
752
+ transform: scale(0.95);
753
+ }
754
+ .quick-reply-btn.animated {
755
+ animation: messageAppear var(--animation-time) ease-out forwards;
756
+ opacity: 0;
757
+ }
758
+ `;
759
+ }
760
+
761
+ @property({ type: String })
762
+ flow = '';
763
+
764
+ @property({ type: String })
765
+ endpoint = '';
766
+
767
+ @property({ type: Number })
768
+ animationTime = 200;
769
+
770
+ @fromCookie('simulator-size', 'small')
771
+ size: 'small' | 'medium' | 'large';
772
+
773
+ @property({ type: Array })
774
+ private events: Event[] = [];
775
+
776
+ private previousEventCount = 0;
777
+
778
+ @property({ type: Object })
779
+ private session: Session | null = null;
780
+
781
+ @property({ type: Object })
782
+ private context: any = null;
783
+
784
+ @property({ type: Object })
785
+ private contact: Contact = {
786
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
787
+ urns: ['tel:+12065551212'],
788
+ fields: {},
789
+ groups: [],
790
+ language: 'eng',
791
+ status: 'active',
792
+ created_on: new Date().toISOString()
793
+ };
794
+
795
+ @property({ type: Boolean })
796
+ private sprinting = false;
797
+
798
+ @property({ type: String })
799
+ private inputValue = '';
800
+
801
+ @fromCookie('simulator-follow', true)
802
+ private following: boolean;
803
+
804
+ @fromCookie('simulator-context-open', false)
805
+ private contextExplorerOpen: boolean;
806
+
807
+ @property({ type: Object })
808
+ private expandedPaths: Set<string> = new Set();
809
+
810
+ @property({ type: String })
811
+ private copiedExpression = '';
812
+
813
+ @property({ type: String })
814
+ private toastMessage = '';
815
+
816
+ @property({ type: Boolean })
817
+ private showAllKeys = true;
818
+
819
+ private previousWindowWidth = 0;
820
+
821
+ @property({ type: Array })
822
+ private currentQuickReplies: any[] = [];
823
+
824
+ @property({ type: Boolean })
825
+ private isVisible = false;
826
+
827
+ @property({ type: Boolean })
828
+ private attachmentMenuOpen = false;
829
+
830
+ private boundClickOutsideHandler: ((event: MouseEvent) => void) | null = null;
831
+
832
+ // attachment cycling indices - initialized randomly
833
+ private imageIndex = Math.floor(Math.random() * TEST_IMAGES.length);
834
+ private videoIndex = Math.floor(Math.random() * TEST_VIDEOS.length);
835
+ private audioIndex = Math.floor(Math.random() * TEST_AUDIO.length);
836
+ private locationIndex = Math.floor(Math.random() * TEST_LOCATIONS.length);
837
+
838
+ // method to reset attachment indices for testing
839
+ public resetAttachmentIndices() {
840
+ this.imageIndex = 2;
841
+ this.videoIndex = 0;
842
+ this.audioIndex = 0;
843
+ this.locationIndex = 0;
844
+ }
845
+
846
+ private get sizeConfig(): SimulatorSize {
847
+ return SIMULATOR_SIZES[this.size] || SIMULATOR_SIZES.medium;
848
+ }
849
+
850
+ private get windowWidth(): number {
851
+ const config = this.sizeConfig;
852
+ return (
853
+ config.contextWidth +
854
+ config.phoneWidth +
855
+ config.optionPaneWidth +
856
+ config.optionPaneGap +
857
+ config.contextOffset
858
+ );
859
+ }
860
+
861
+ private get leftBoundaryMargin(): number {
862
+ const config = this.sizeConfig;
863
+ return config.contextWidth + config.contextOffset;
864
+ }
865
+
866
+ private get contextClosedLeft(): number {
867
+ const config = this.sizeConfig;
868
+ return config.contextWidth + config.contextOffset - config.phoneWidth;
869
+ }
870
+
871
+ protected updated(
872
+ changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
873
+ ): void {
874
+ super.updated(changes);
875
+ if (changes.has('flow') && this.flow) {
876
+ this.endpoint = `/flow/simulate/${this.flow}/`;
877
+ }
878
+
879
+ // handle attachment menu click outside listener
880
+ if (changes.has('attachmentMenuOpen')) {
881
+ if (this.attachmentMenuOpen) {
882
+ // create bound handler if it doesn't exist
883
+ if (!this.boundClickOutsideHandler) {
884
+ this.boundClickOutsideHandler =
885
+ this.handleClickOutsideAttachmentMenu.bind(this);
886
+ }
887
+ // add listener when menu opens
888
+ setTimeout(() => {
889
+ document.addEventListener('click', this.boundClickOutsideHandler);
890
+ }, 0);
891
+ } else {
892
+ // remove listener when menu closes
893
+ if (this.boundClickOutsideHandler) {
894
+ document.removeEventListener('click', this.boundClickOutsideHandler);
895
+ }
896
+ }
897
+ }
898
+
899
+ // update floating window boundaries when size changes
900
+ if (changes.has('size')) {
901
+ requestAnimationFrame(() => {
902
+ const phoneWindow = this.shadowRoot?.getElementById(
903
+ 'phone-window'
904
+ ) as FloatingWindow;
905
+ if (phoneWindow) {
906
+ // use the stored previous width since phoneWindow.width has already been updated
907
+ const oldWidth = this.previousWindowWidth || phoneWindow.width;
908
+ const oldRight = phoneWindow.left + oldWidth;
909
+
910
+ const config = this.sizeConfig;
911
+ const newWidth = this.windowWidth;
912
+
913
+ // store current width for next size change
914
+ this.previousWindowWidth = newWidth;
915
+
916
+ // update dimensions and boundaries
917
+ phoneWindow.width = newWidth;
918
+ phoneWindow.leftBoundaryMargin = this.leftBoundaryMargin;
919
+ phoneWindow.topBoundaryMargin = config.windowPadding;
920
+ phoneWindow.bottomBoundaryMargin = config.windowPadding;
921
+
922
+ // keep right edge in same position by adjusting left
923
+ let newLeft = oldRight - newWidth;
924
+
925
+ // apply same boundary logic as FloatingWindow.handleMouseMove
926
+ const padding = 20;
927
+ const minLeft = padding - this.leftBoundaryMargin;
928
+ const maxLeft =
929
+ window.innerWidth -
930
+ newWidth -
931
+ padding +
932
+ phoneWindow.rightBoundaryMargin;
933
+
934
+ // clamp to boundaries
935
+ newLeft = Math.max(minLeft, Math.min(newLeft, maxLeft));
936
+
937
+ phoneWindow.left = newLeft;
938
+
939
+ // adjust vertical position if needed
940
+ const windowElement = phoneWindow.shadowRoot?.querySelector(
941
+ '.window'
942
+ ) as HTMLElement;
943
+ const currentHeight =
944
+ windowElement?.offsetHeight || config.phoneTotalHeight;
945
+ const maxTop = Math.max(
946
+ padding - config.windowPadding,
947
+ window.innerHeight - currentHeight - padding + config.windowPadding
948
+ );
949
+
950
+ phoneWindow.top = Math.max(
951
+ padding - config.windowPadding,
952
+ Math.min(phoneWindow.top, maxTop)
953
+ );
954
+ }
955
+ });
956
+ } else {
957
+ // store initial width when first rendered
958
+ if (!this.previousWindowWidth) {
959
+ this.previousWindowWidth = this.windowWidth;
960
+ }
961
+ }
962
+ }
963
+
964
+ disconnectedCallback(): void {
965
+ super.disconnectedCallback();
966
+ // clean up event listener when component is removed
967
+ if (this.boundClickOutsideHandler) {
968
+ document.removeEventListener('click', this.boundClickOutsideHandler);
969
+ }
970
+ }
971
+
972
+ private handleShow() {
973
+ const phoneWindow = this.shadowRoot.getElementById(
974
+ 'phone-window'
975
+ ) as FloatingWindow;
976
+ phoneWindow.show();
977
+ this.isVisible = true;
978
+ getStore().getState().setSimulatorActive(true);
979
+
980
+ // start the simulation if we haven't already
981
+ if (this.events.length === 0) {
982
+ this.startFlow();
983
+ }
984
+ }
985
+
986
+ private async startFlow() {
987
+ const now = new Date().toISOString();
988
+
989
+ // set created_on to simulation start time
990
+ this.contact = { ...this.contact, created_on: now };
991
+
992
+ const body = {
993
+ contact: this.contact,
994
+ trigger: {
995
+ type: 'manual',
996
+ triggered_on: now,
997
+ flow: { uuid: this.flow, name: 'New Chat' },
998
+ params: {}
999
+ }
1000
+ };
1001
+
1002
+ try {
1003
+ const response = await postJSON(this.endpoint, body);
1004
+ this.updateRunContext(response.json as RunContext);
1005
+ } catch (error) {
1006
+ console.error('Failed to start simulation:', error);
1007
+ this.events = [
1008
+ ...this.events,
1009
+ {
1010
+ type: 'error',
1011
+ created_on: now,
1012
+ text: 'Failed to start simulation'
1013
+ } as any
1014
+ ];
1015
+ }
1016
+ }
1017
+
1018
+ private updateRunContext(runContext: RunContext, msgInEvt?: Event) {
1019
+ if (msgInEvt) {
1020
+ this.events = [...this.events, msgInEvt];
1021
+ }
1022
+
1023
+ if (runContext.session) {
1024
+ this.session = runContext.session;
1025
+
1026
+ // update our contact with the latest from the session
1027
+ if (runContext.contact) {
1028
+ this.contact = runContext.contact;
1029
+ }
1030
+ }
1031
+
1032
+ // store the context from the response
1033
+ if (runContext.context) {
1034
+ this.context = runContext.context;
1035
+ }
1036
+
1037
+ if (runContext.events && runContext.events.length > 0) {
1038
+ this.events = [...this.events, ...runContext.events];
1039
+
1040
+ // extract quick replies from the most recent sprint
1041
+ this.currentQuickReplies = [];
1042
+ for (const event of runContext.events) {
1043
+ if (event.type === 'msg_created' && event.msg?.quick_replies) {
1044
+ this.currentQuickReplies = event.msg.quick_replies;
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ this.sprinting = false;
1050
+ this.requestUpdate();
1051
+ this.scrollToBottom();
1052
+ this.updateActivity();
1053
+ }
1054
+
1055
+ private updateActivity() {
1056
+ if (!this.session) {
1057
+ return;
1058
+ }
1059
+
1060
+ const pathCounts: { [key: string]: number } = {};
1061
+ const nodeCounts: { [nodeUUID: string]: number } = {};
1062
+
1063
+ // iterate through all runs to get path segment counts
1064
+ for (const run of this.session.runs) {
1065
+ if (run.path) {
1066
+ for (let i = 0; i < run.path.length - 1; i++) {
1067
+ const step = run.path[i];
1068
+ const nextStep = run.path[i + 1];
1069
+ if (step.exit_uuid && nextStep.node_uuid) {
1070
+ const key = step.exit_uuid + ':' + nextStep.node_uuid;
1071
+ pathCounts[key] = (pathCounts[key] || 0) + 1;
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ // set node counts on the last step of any active/waiting runs
1077
+ if (run.status === 'active' || run.status === 'waiting') {
1078
+ if (run.path && run.path.length > 0) {
1079
+ const finalStep = run.path[run.path.length - 1];
1080
+ if (finalStep && finalStep.node_uuid) {
1081
+ nodeCounts[finalStep.node_uuid] =
1082
+ (nodeCounts[finalStep.node_uuid] || 0) + 1;
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ // Update simulator activity in the store
1089
+ getStore().getState().updateSimulatorActivity({
1090
+ segments: pathCounts,
1091
+ nodes: nodeCounts
1092
+ });
1093
+
1094
+ // Fire follow event if following is enabled
1095
+ if (this.following) {
1096
+ this.fireFollowEvent();
1097
+ }
1098
+ }
1099
+
1100
+ private fireFollowEvent() {
1101
+ if (!this.session || !this.session.runs || this.session.runs.length === 0) {
1102
+ return;
1103
+ }
1104
+
1105
+ // Find the first active or waiting run
1106
+ let activeRun = this.session.runs.find(
1107
+ (run: any) => run.status === 'active' || run.status === 'waiting'
1108
+ );
1109
+
1110
+ // If no active/waiting run and simulation has ended, use the first completed run
1111
+ if (!activeRun) {
1112
+ activeRun = this.session.runs.find(
1113
+ (run: any) => run.status === 'completed'
1114
+ );
1115
+ }
1116
+
1117
+ if (activeRun && activeRun.path && activeRun.path.length > 0) {
1118
+ const finalStep = activeRun.path[activeRun.path.length - 1];
1119
+ if (finalStep && finalStep.node_uuid) {
1120
+ this.fireCustomEvent(CustomEventType.FollowSimulation, {
1121
+ flowUuid: activeRun.flow?.uuid || this.flow,
1122
+ nodeUuid: finalStep.node_uuid
1123
+ });
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ private scrollToBottom() {
1129
+ // wait for render, then scroll to bottom
1130
+ setTimeout(() => {
1131
+ const screen = this.shadowRoot?.querySelector('.phone-screen');
1132
+ if (screen) {
1133
+ screen.scrollTop = screen.scrollHeight;
1134
+ }
1135
+ // update previous count after animation completes
1136
+ this.previousEventCount = this.events.length;
1137
+
1138
+ // return focus to input
1139
+ const input = this.shadowRoot?.querySelector(
1140
+ '.message-input input'
1141
+ ) as HTMLInputElement;
1142
+ if (input) {
1143
+ input.focus();
1144
+ }
1145
+ }, 50);
1146
+ }
1147
+
1148
+ private handleClose() {
1149
+ const phoneWindow = this.shadowRoot.getElementById(
1150
+ 'phone-window'
1151
+ ) as FloatingWindow;
1152
+ // phoneWindow.hide();
1153
+
1154
+ phoneWindow.handleClose();
1155
+ this.isVisible = false;
1156
+ getStore().getState().setSimulatorActive(false);
1157
+ }
1158
+
1159
+ private handleReset() {
1160
+ // reset simulation state
1161
+ this.events = [];
1162
+ this.session = null;
1163
+ this.context = null;
1164
+ this.inputValue = '';
1165
+ this.sprinting = false;
1166
+ this.previousEventCount = 0;
1167
+ this.currentQuickReplies = [];
1168
+
1169
+ // Clear simulator activity data
1170
+ getStore().getState().updateSimulatorActivity({
1171
+ segments: {},
1172
+ nodes: {}
1173
+ });
1174
+
1175
+ // reset contact to initial state
1176
+ this.contact = {
1177
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
1178
+ urns: ['tel:+12065551212'],
1179
+ fields: {},
1180
+ groups: [],
1181
+ language: 'eng',
1182
+ status: 'active',
1183
+ created_on: new Date().toISOString()
1184
+ };
1185
+
1186
+ // restart the flow
1187
+ this.startFlow();
1188
+ }
1189
+
1190
+ private handleToggleFollow() {
1191
+ this.following = !this.following;
1192
+ }
1193
+
1194
+ private handleCycleSize() {
1195
+ const sizes: Array<'small' | 'medium' | 'large'> = [
1196
+ 'small',
1197
+ 'medium',
1198
+ 'large'
1199
+ ];
1200
+ const currentIndex = sizes.indexOf(this.size);
1201
+ const nextIndex = (currentIndex + 1) % sizes.length;
1202
+ this.size = sizes[nextIndex];
1203
+ }
1204
+
1205
+ private handleToggleContextExplorer() {
1206
+ this.contextExplorerOpen = !this.contextExplorerOpen;
1207
+
1208
+ // if opening the context explorer, ensure it's not off-screen
1209
+ if (this.contextExplorerOpen) {
1210
+ requestAnimationFrame(() => {
1211
+ const phoneWindow = this.shadowRoot?.getElementById(
1212
+ 'phone-window'
1213
+ ) as FloatingWindow;
1214
+ if (phoneWindow) {
1215
+ const padding = 20;
1216
+ const contextExplorerLeft = this.sizeConfig.contextOffset;
1217
+ const minWindowLeft = padding - contextExplorerLeft;
1218
+
1219
+ if (phoneWindow.left < minWindowLeft) {
1220
+ phoneWindow.left = minWindowLeft;
1221
+ }
1222
+ }
1223
+ });
1224
+ }
1225
+ }
1226
+
1227
+ private togglePath(path: string) {
1228
+ if (this.expandedPaths.has(path)) {
1229
+ this.expandedPaths.delete(path);
1230
+ } else {
1231
+ this.expandedPaths.add(path);
1232
+ }
1233
+ this.requestUpdate();
1234
+ }
1235
+
1236
+ private isExpandable(value: any): boolean {
1237
+ if (value === null || typeof value !== 'object') {
1238
+ return false;
1239
+ }
1240
+
1241
+ if (Array.isArray(value)) {
1242
+ return value.length > 0;
1243
+ }
1244
+
1245
+ // check if object has keys other than __default__
1246
+ const keys = Object.keys(value).filter((key) => key !== '__default__');
1247
+ return keys.length > 0;
1248
+ }
1249
+
1250
+ private renderContextValue(value: any): TemplateResult | string {
1251
+ if (value === null || value === undefined) return '';
1252
+ if (typeof value === 'boolean')
1253
+ return html`<span class="context-value">${value}</span>`;
1254
+ if (typeof value === 'number')
1255
+ return html`<span class="context-value">${value}</span>`;
1256
+ if (typeof value === 'string')
1257
+ return html`<span class="context-value">${value}</span>`;
1258
+ if (Array.isArray(value))
1259
+ return html`<span class="context-value">[${value.length}]</span>`;
1260
+ return '';
1261
+ }
1262
+
1263
+ private buildExpression(path: string): string {
1264
+ return `@${path}`;
1265
+ }
1266
+
1267
+ private async handleCopyExpression(
1268
+ path: string,
1269
+ event: Event
1270
+ ): Promise<void> {
1271
+ event.stopPropagation();
1272
+ const expression = this.buildExpression(path);
1273
+ try {
1274
+ await navigator.clipboard.writeText(expression);
1275
+ this.copiedExpression = expression;
1276
+ // clear the toast after 2 seconds
1277
+ setTimeout(() => {
1278
+ this.copiedExpression = '';
1279
+ }, 2000);
1280
+ } catch (err) {
1281
+ console.error('Failed to copy expression:', err);
1282
+ }
1283
+ }
1284
+
1285
+ private handleToggleShowAllKeys() {
1286
+ this.showAllKeys = !this.showAllKeys;
1287
+ this.toastMessage = this.showAllKeys
1288
+ ? 'Showing all keys'
1289
+ : 'Filtering out keys without values';
1290
+ // clear the toast after 2 seconds
1291
+ setTimeout(() => {
1292
+ this.toastMessage = '';
1293
+ }, 2000);
1294
+ }
1295
+
1296
+ private renderContextTree(
1297
+ obj: any,
1298
+ path: string = ''
1299
+ ): TemplateResult | TemplateResult[] {
1300
+ if (!obj || typeof obj !== 'object') {
1301
+ return html``;
1302
+ }
1303
+
1304
+ let entries = Array.isArray(obj)
1305
+ ? obj.map((v, i) => [String(i), v])
1306
+ : Object.entries(obj).filter(([key]) => key !== '__default__');
1307
+
1308
+ // filter out keys without values if showAllKeys is false
1309
+ if (!this.showAllKeys) {
1310
+ entries = entries.filter(([, value]) => {
1311
+ // keep if expandable (has children)
1312
+ if (this.isExpandable(value)) return true;
1313
+ // keep if it has a displayable value (not null/undefined)
1314
+ if (value === null || value === undefined) return false;
1315
+ // keep primitives with values
1316
+ return (
1317
+ typeof value === 'boolean' ||
1318
+ typeof value === 'number' ||
1319
+ typeof value === 'string' ||
1320
+ Array.isArray(value)
1321
+ );
1322
+ });
1323
+ }
1324
+
1325
+ return html`${entries.map(([key, value]) => {
1326
+ const currentPath = path ? `${path}.${key}` : key;
1327
+ const isExpanded = this.expandedPaths.has(currentPath);
1328
+ const expandable = this.isExpandable(value);
1329
+
1330
+ // check if this object has a __default__ value
1331
+ let displayValue = value;
1332
+
1333
+ if (
1334
+ expandable &&
1335
+ !Array.isArray(value) &&
1336
+ value !== null &&
1337
+ typeof value === 'object' &&
1338
+ '__default__' in value
1339
+ ) {
1340
+ displayValue = value.__default__;
1341
+ }
1342
+
1343
+ return html`
1344
+ <div>
1345
+ <div
1346
+ class="context-item ${expandable ? 'context-item-expandable' : ''}"
1347
+ @click=${() => expandable && this.togglePath(currentPath)}
1348
+ >
1349
+ ${expandable
1350
+ ? html`<span
1351
+ class="context-expand-icon ${isExpanded ? 'expanded' : ''}"
1352
+ >›</span
1353
+ >`
1354
+ : html`<span class="context-expand-icon"></span>`}
1355
+ <span class="context-key ${expandable ? 'has-value' : ''}"
1356
+ >${key}
1357
+ <temba-icon
1358
+ class="context-copy-icon"
1359
+ name="copy"
1360
+ size="0.9"
1361
+ @click=${(e: Event) =>
1362
+ this.handleCopyExpression(currentPath, e)}
1363
+ ></temba-icon>
1364
+ </span>
1365
+ ${!isExpanded ? this.renderContextValue(displayValue) : html``}
1366
+ </div>
1367
+ ${isExpanded
1368
+ ? html`<div class="context-children">
1369
+ ${this.renderContextTree(value, currentPath)}
1370
+ </div>`
1371
+ : html``}
1372
+ </div>
1373
+ `;
1374
+ })}`;
1375
+ }
1376
+
1377
+ private async resume(text: string, attachment?: string) {
1378
+ if ((!text && !attachment) || !this.session) {
1379
+ return;
1380
+ }
1381
+
1382
+ this.sprinting = true;
1383
+ this.inputValue = '';
1384
+ this.currentQuickReplies = [];
1385
+ this.attachmentMenuOpen = false;
1386
+
1387
+ const now = new Date().toISOString();
1388
+ const msgInEvt: Event = {
1389
+ uuid: crypto.randomUUID(),
1390
+ type: 'msg_received',
1391
+ created_on: now,
1392
+ msg: {
1393
+ uuid: crypto.randomUUID(),
1394
+ text: text || '',
1395
+ urn: this.contact.urns[0],
1396
+ attachments: attachment ? [attachment] : []
1397
+ }
1398
+ };
1399
+
1400
+ // show user's message immediately
1401
+ this.events = [...this.events, msgInEvt];
1402
+ this.requestUpdate();
1403
+ this.scrollToBottom();
1404
+
1405
+ const body = {
1406
+ session: this.session,
1407
+ contact: this.contact,
1408
+ resume: {
1409
+ type: 'msg',
1410
+ event: msgInEvt,
1411
+ resumed_on: now
1412
+ }
1413
+ };
1414
+
1415
+ try {
1416
+ const response = await postJSON(this.endpoint, body);
1417
+
1418
+ // add a small delay before showing the reply to simulate typing
1419
+ await new Promise((resolve) => setTimeout(resolve, 400));
1420
+
1421
+ // pass null for msgInEvt since we already added it
1422
+ this.updateRunContext(response.json as RunContext, null);
1423
+ } catch (error) {
1424
+ console.error('Failed to resume simulation:', error);
1425
+ this.events = [
1426
+ ...this.events,
1427
+ {
1428
+ type: 'error',
1429
+ created_on: now,
1430
+ text: 'Failed to send message'
1431
+ } as any
1432
+ ];
1433
+ this.sprinting = false;
1434
+ }
1435
+ }
1436
+
1437
+ private handleKeyUp(evt: KeyboardEvent) {
1438
+ if (evt.key === 'Enter') {
1439
+ const input = evt.target as HTMLInputElement;
1440
+ const text = input.value.trim();
1441
+ if (text) {
1442
+ this.resume(text);
1443
+ }
1444
+ }
1445
+ }
1446
+
1447
+ private handleInput(evt: Event) {
1448
+ const input = evt.target as HTMLInputElement;
1449
+ this.inputValue = input.value;
1450
+ }
1451
+
1452
+ private handleQuickReply(quickReply: string) {
1453
+ if (!this.sprinting) {
1454
+ this.resume(quickReply);
1455
+ }
1456
+ }
1457
+
1458
+ private handleToggleAttachmentMenu() {
1459
+ this.attachmentMenuOpen = !this.attachmentMenuOpen;
1460
+ }
1461
+
1462
+ private handleClickOutsideAttachmentMenu(event: MouseEvent) {
1463
+ if (!this.attachmentMenuOpen) {
1464
+ return;
1465
+ }
1466
+
1467
+ const menu = this.shadowRoot?.querySelector('.attachment-menu');
1468
+ const button = this.shadowRoot?.querySelector('.attachment-button');
1469
+
1470
+ if (!menu || !button) {
1471
+ return;
1472
+ }
1473
+
1474
+ // check if click is outside both menu and button
1475
+ const clickedInsideMenu = menu.contains(event.target as Node);
1476
+ const clickedInsideButton = button.contains(event.target as Node);
1477
+
1478
+ if (!clickedInsideMenu && !clickedInsideButton) {
1479
+ this.attachmentMenuOpen = false;
1480
+ }
1481
+ }
1482
+
1483
+ private handleSendAttachment(attachmentType: string) {
1484
+ let attachment = '';
1485
+ switch (attachmentType) {
1486
+ case 'image':
1487
+ attachment = `image/jpeg:${TEST_IMAGES[this.imageIndex]}`;
1488
+ this.imageIndex = (this.imageIndex + 1) % TEST_IMAGES.length;
1489
+ break;
1490
+ case 'video':
1491
+ attachment = `video/mp4:${TEST_VIDEOS[this.videoIndex]}`;
1492
+ this.videoIndex = (this.videoIndex + 1) % TEST_VIDEOS.length;
1493
+ break;
1494
+ case 'audio':
1495
+ attachment = `audio/mp3:${TEST_AUDIO[this.audioIndex]}`;
1496
+ this.audioIndex = (this.audioIndex + 1) % TEST_AUDIO.length;
1497
+ break;
1498
+ case 'location':
1499
+ attachment = TEST_LOCATIONS[this.locationIndex];
1500
+ this.locationIndex = (this.locationIndex + 1) % TEST_LOCATIONS.length;
1501
+ break;
1502
+ }
1503
+
1504
+ if (attachment) {
1505
+ this.resume('', attachment);
1506
+ }
1507
+ }
1508
+
1509
+ private getEventDescription(event: Event): string | null {
1510
+ switch (event.type) {
1511
+ case 'contact_groups_changed': {
1512
+ const groups = (event as any).groups_added || [];
1513
+ const removedGroups = (event as any).groups_removed || [];
1514
+ if (groups.length > 0) {
1515
+ const groupNames = groups.map((g: any) => `"${g.name}"`).join(', ');
1516
+ return `Added to ${groupNames}`;
1517
+ }
1518
+ if (removedGroups.length > 0) {
1519
+ const groupNames = removedGroups
1520
+ .map((g: any) => `"${g.name}"`)
1521
+ .join(', ');
1522
+ return `Removed from ${groupNames}`;
1523
+ }
1524
+ break;
1525
+ }
1526
+ case 'contact_field_changed': {
1527
+ const field = (event as any).field;
1528
+ const value = (event as any).value;
1529
+ const valueText = value ? value.text || value : '';
1530
+ if (field) {
1531
+ if (valueText) {
1532
+ return `Set contact "${field.name}" to "${valueText}"`;
1533
+ } else {
1534
+ return `Cleared contact "${field.name}"`;
1535
+ }
1536
+ }
1537
+ break;
1538
+ }
1539
+ case 'contact_language_changed':
1540
+ return `Set preferred language to "${(event as any).language}"`;
1541
+ case 'contact_name_changed':
1542
+ return `Set contact name to "${(event as any).name}"`;
1543
+ case 'contact_status_changed':
1544
+ return `Set status to "${(event as any).status}"`;
1545
+ case 'contact_urns_changed':
1546
+ return `Added a URN for the contact`;
1547
+ case 'input_labels_added': {
1548
+ const labels = (event as any).labels || [];
1549
+ if (labels.length > 0) {
1550
+ const labelNames = labels.map((l: any) => `"${l.name}"`).join(', ');
1551
+ return `Message labeled with ${labelNames}`;
1552
+ }
1553
+ break;
1554
+ }
1555
+ case 'run_result_changed':
1556
+ return `Set result "${(event as any).name}" to "${
1557
+ (event as any).value
1558
+ }"`;
1559
+ case 'run_started':
1560
+ case 'flow_entered': {
1561
+ const flow = (event as any).flow;
1562
+ if (flow) {
1563
+ return `Entered flow "${flow.name}"`;
1564
+ }
1565
+ break;
1566
+ }
1567
+ case 'run_ended': {
1568
+ const flow = (event as any).flow;
1569
+ if (flow) {
1570
+ return `Exited flow "${flow.name}"`;
1571
+ }
1572
+ break;
1573
+ }
1574
+ case 'email_created':
1575
+ case 'email_sent': {
1576
+ const recipients = (event as any).to || (event as any).addresses || [];
1577
+ const subject = (event as any).subject;
1578
+ const recipientList = recipients
1579
+ .map((r: string) => `"${r}"`)
1580
+ .join(', ');
1581
+ return `Sent email to ${recipientList} with subject "${subject}"`;
1582
+ }
1583
+ case 'broadcast_created': {
1584
+ const translations = (event as any).translations;
1585
+ const baseLanguage = (event as any).base_language;
1586
+ if (translations && translations[baseLanguage]) {
1587
+ return `Sent broadcast: "${translations[baseLanguage].text}"`;
1588
+ }
1589
+ return `Sent broadcast`;
1590
+ }
1591
+ case 'session_triggered': {
1592
+ const flow = (event as any).flow;
1593
+ if (flow) {
1594
+ return `Started somebody else in "${flow.name}"`;
1595
+ }
1596
+ break;
1597
+ }
1598
+ case 'ticket_opened': {
1599
+ const ticket = (event as any).ticket;
1600
+ if (ticket && ticket.topic) {
1601
+ return `Ticket opened with topic "${ticket.topic.name}"`;
1602
+ }
1603
+ return `Ticket opened`;
1604
+ }
1605
+ case 'resthook_called':
1606
+ return `Triggered flow event "${(event as any).resthook}"`;
1607
+ case 'webhook_called':
1608
+ return `Called ${(event as any).url}`;
1609
+ case 'service_called': {
1610
+ const service = (event as any).service;
1611
+ if (service === 'classifier') {
1612
+ return `Called classifier`;
1613
+ }
1614
+ return `Called ${service}`;
1615
+ }
1616
+ case 'airtime_transferred': {
1617
+ const amount = (event as any).actual_amount;
1618
+ const currency = (event as any).currency;
1619
+ const recipient = (event as any).recipient;
1620
+ if (amount && currency && recipient) {
1621
+ return `Transferred ${amount} ${currency} to ${recipient}`;
1622
+ }
1623
+ break;
1624
+ }
1625
+ case 'info':
1626
+ return (event as any).text;
1627
+ case 'warning':
1628
+ return `⚠️ ${(event as any).text}`;
1629
+ }
1630
+ return null;
1631
+ }
1632
+
1633
+ private renderAlertMessage(
1634
+ type: 'error' | 'warning' | 'failure',
1635
+ text: string,
1636
+ animatedClass: string,
1637
+ animationDelay: string
1638
+ ): TemplateResult {
1639
+ const config = {
1640
+ error: {
1641
+ icon: '❗',
1642
+ bgColor: '#fee2e2',
1643
+ textColor: '#991b1b',
1644
+ defaultText: 'An error occurred'
1645
+ },
1646
+ warning: {
1647
+ icon: '⚠️',
1648
+ bgColor: '#fef3c7',
1649
+ textColor: 'rgba(125, 87, 18, 0.8)',
1650
+ defaultText: 'A warning occurred'
1651
+ },
1652
+ failure: {
1653
+ icon: '💥',
1654
+ bgColor: '#fee2e2',
1655
+ textColor: '#991b1b',
1656
+ defaultText: 'A failure occurred'
1657
+ }
1658
+ }[type];
1659
+
1660
+ return html`
1661
+ <div
1662
+ class="event-info ${animatedClass}"
1663
+ style="display:flex; align-items:center; background: ${config.bgColor}; color: ${config.textColor}; padding: 6px; margin: 4px 12px; border-radius: 8px; animation-delay: ${animationDelay}"
1664
+ >
1665
+ <div style="padding:4px;margin-right:6px;font-size:15px">
1666
+ ${config.icon}
1667
+ </div>
1668
+ <div style="padding-right:2px;text-align:left">
1669
+ ${text || config.defaultText}
1670
+ </div>
1671
+ </div>
1672
+ `;
1673
+ }
1674
+
1675
+ private renderAttachment(attachment: string): TemplateResult {
1676
+ // parse attachment format: "type/subtype:url" or "geo:lat,long"
1677
+ const parts = attachment.split(':');
1678
+ const type = parts[0];
1679
+ const content = parts.slice(1).join(':'); // rejoin in case url has colons
1680
+
1681
+ if (type === 'geo') {
1682
+ // use temba-thumbnail for location to get map image
1683
+ return html`
1684
+ <div class="attachment-location">
1685
+ <temba-thumbnail attachment="${attachment}"></temba-thumbnail>
1686
+ </div>
1687
+ `;
1688
+ } else if (type.startsWith('image/')) {
1689
+ // custom image rendering
1690
+ return html`
1691
+ <div class="attachment">
1692
+ <img src="${content}" alt="Image attachment" />
1693
+ </div>
1694
+ `;
1695
+ } else if (type.startsWith('video/')) {
1696
+ // custom video rendering
1697
+ return html`
1698
+ <div class="attachment">
1699
+ <video controls>
1700
+ <source src="${content}" type="${type}" />
1701
+ </video>
1702
+ </div>
1703
+ `;
1704
+ } else if (type.startsWith('audio/')) {
1705
+ // custom audio rendering
1706
+ return html`
1707
+ <div class="attachment">
1708
+ <div class="attachment-audio">
1709
+ <audio controls>
1710
+ <source src="${content}" type="${type}" />
1711
+ </audio>
1712
+ </div>
1713
+ </div>
1714
+ `;
1715
+ }
1716
+
1717
+ // fallback for unknown types
1718
+ return html`
1719
+ <div class="attachment">
1720
+ <span>Attachment</span>
1721
+ </div>
1722
+ `;
1723
+ }
1724
+
1725
+ private renderMessages(): TemplateResult {
1726
+ if (this.events.length === 0) {
1727
+ return html`
1728
+ <div class="message incoming">👋 Welcome! Starting simulation...</div>
1729
+ `;
1730
+ }
1731
+
1732
+ const eventTemplates = this.events.map((event, index) => {
1733
+ // only animate messages that are new (beyond previous count)
1734
+ const isNew = index >= this.previousEventCount;
1735
+ const animatedClass = isNew ? 'animated' : '';
1736
+ // stagger animations for new messages
1737
+ const animationDelay = isNew
1738
+ ? `${(index - this.previousEventCount) * 0.2}s`
1739
+ : '0s';
1740
+
1741
+ if (event.type === 'msg_received' && event.msg) {
1742
+ const hasAttachments =
1743
+ event.msg.attachments && event.msg.attachments.length > 0;
1744
+ const hasText = event.msg.text && event.msg.text.trim().length > 0;
1745
+
1746
+ return html`
1747
+ ${hasAttachments
1748
+ ? html`
1749
+ <div
1750
+ class="attachment-wrapper outgoing ${animatedClass}"
1751
+ style="animation-delay: ${animationDelay}"
1752
+ >
1753
+ ${event.msg.attachments.map((att: string) =>
1754
+ this.renderAttachment(att)
1755
+ )}
1756
+ </div>
1757
+ `
1758
+ : html``}
1759
+ ${hasText
1760
+ ? html`
1761
+ <div
1762
+ class="message outgoing ${animatedClass}"
1763
+ style="animation-delay: ${animationDelay}"
1764
+ >
1765
+ ${event.msg.text}
1766
+ </div>
1767
+ `
1768
+ : html``}
1769
+ `;
1770
+ } else if (event.type === 'msg_created' && event.msg) {
1771
+ const hasAttachments =
1772
+ event.msg.attachments && event.msg.attachments.length > 0;
1773
+ const hasText = event.msg.text && event.msg.text.trim().length > 0;
1774
+
1775
+ return html`
1776
+ ${hasAttachments
1777
+ ? html`
1778
+ <div
1779
+ class="attachment-wrapper incoming ${animatedClass}"
1780
+ style="animation-delay: ${animationDelay}"
1781
+ >
1782
+ ${event.msg.attachments.map((att: string) =>
1783
+ this.renderAttachment(att)
1784
+ )}
1785
+ </div>
1786
+ `
1787
+ : html``}
1788
+ ${hasText
1789
+ ? html`
1790
+ <div
1791
+ class="message incoming ${animatedClass}"
1792
+ style="animation-delay: ${animationDelay}"
1793
+ >
1794
+ ${event.msg.text}
1795
+ </div>
1796
+ `
1797
+ : html``}
1798
+ `;
1799
+ } else if (event.type === 'failure') {
1800
+ return this.renderAlertMessage(
1801
+ 'failure',
1802
+ (event as any).text,
1803
+ animatedClass,
1804
+ animationDelay
1805
+ );
1806
+ } else if (event.type === 'warning') {
1807
+ return this.renderAlertMessage(
1808
+ 'warning',
1809
+ (event as any).text,
1810
+ animatedClass,
1811
+ animationDelay
1812
+ );
1813
+ } else if (event.type === 'error') {
1814
+ return this.renderAlertMessage(
1815
+ 'error',
1816
+ (event as any).text,
1817
+ animatedClass,
1818
+ animationDelay
1819
+ );
1820
+ } else {
1821
+ // check if this is an event we should display
1822
+ const description = this.getEventDescription(event);
1823
+ if (description) {
1824
+ return html`
1825
+ <div
1826
+ class="event-info ${animatedClass}"
1827
+ style="animation-delay: ${animationDelay}"
1828
+ >
1829
+ ${description}
1830
+ </div>
1831
+ `;
1832
+ }
1833
+ }
1834
+ return html``;
1835
+ });
1836
+
1837
+ // render quick replies at the end if we have any from the most recent sprint
1838
+ const hasQuickReplies = this.currentQuickReplies.length > 0;
1839
+ const quickRepliesAnimationDelay =
1840
+ this.events.length >= this.previousEventCount
1841
+ ? `${(this.events.length - this.previousEventCount) * 0.2}s`
1842
+ : '0s';
1843
+
1844
+ return html`
1845
+ ${eventTemplates}
1846
+ ${hasQuickReplies
1847
+ ? html`
1848
+ <div
1849
+ class="quick-replies animated"
1850
+ style="animation-delay: ${quickRepliesAnimationDelay}"
1851
+ >
1852
+ ${this.currentQuickReplies.map(
1853
+ (qr: any) => html`
1854
+ <button
1855
+ class="quick-reply-btn animated"
1856
+ style="animation-delay: ${quickRepliesAnimationDelay}"
1857
+ @click=${() => this.handleQuickReply(qr.text)}
1858
+ >
1859
+ ${qr.text}
1860
+ </button>
1861
+ `
1862
+ )}
1863
+ </div>
1864
+ `
1865
+ : html``}
1866
+ `;
1867
+ }
1868
+
1869
+ protected render(): TemplateResult {
1870
+ const config = this.sizeConfig;
1871
+
1872
+ // set CSS custom properties dynamically based on size
1873
+ const styleVars = `
1874
+ --phone-width: ${config.phoneWidth}px;
1875
+ --phone-total-height: ${config.phoneTotalHeight}px;
1876
+ --context-width: ${config.contextWidth}px;
1877
+ --context-offset: ${config.contextOffset}px;
1878
+ --option-pane-width: ${config.optionPaneWidth}px;
1879
+ --option-pane-gap: ${config.optionPaneGap}px;
1880
+ --window-padding: ${config.windowPadding}px;
1881
+ --phone-screen-height: ${config.phoneScreenHeight}px;
1882
+ --context-height: ${config.contextHeight}px;
1883
+ --context-closed-left: ${this.contextClosedLeft}px;
1884
+ --cutout-height: ${config.cutoutHeight}px;
1885
+ --cutout-padding: ${config.cutoutPadding}px;
1886
+ --cutout-font-size: ${config.cutoutFontSize}px;
1887
+ --cutout-island-width: ${config.cutoutIslandWidth}px;
1888
+ --cutout-island-height: ${config.cutoutIslandHeight}px;
1889
+ --cutout-island-top: ${config.cutoutIslandTop}px;
1890
+ `;
1891
+
1892
+ return html`
1893
+ <temba-floating-window
1894
+ id="phone-window"
1895
+ width="${this.windowWidth}"
1896
+ leftBoundaryMargin="${this.leftBoundaryMargin}"
1897
+ bottomBoundaryMargin="${config.windowPadding}"
1898
+ topBoundaryMargin="${config.windowPadding}"
1899
+ height="${config.phoneTotalHeight}"
1900
+ top="0"
1901
+ chromeless
1902
+ >
1903
+ <div class="phone-simulator" style="${styleVars}">
1904
+ <div
1905
+ class="context-explorer ${this.contextExplorerOpen ? 'open' : ''}"
1906
+ >
1907
+ <div class="context-explorer-scroll">
1908
+ ${this.context
1909
+ ? this.renderContextTree(this.context)
1910
+ : html`<div
1911
+ style="color: #9ca3af; padding: 8px; text-align: center;"
1912
+ >
1913
+ No context available
1914
+ </div>`}
1915
+ </div>
1916
+ <div class="context-gutter">
1917
+ <div
1918
+ class="context-gutter-btn ${this.showAllKeys ? '' : 'active'}"
1919
+ @click=${this.handleToggleShowAllKeys}
1920
+ title="${this.showAllKeys
1921
+ ? 'Show keys with values only'
1922
+ : 'Show all keys'}"
1923
+ >
1924
+ <temba-icon
1925
+ name="${this.showAllKeys ? 'filter' : 'filter'}"
1926
+ size="1"
1927
+ ></temba-icon>
1928
+ </div>
1929
+ <div class="context-gutter-spacer"></div>
1930
+ <div
1931
+ class="context-gutter-btn"
1932
+ @click=${this.handleToggleContextExplorer}
1933
+ title="Close"
1934
+ >
1935
+ <temba-icon name="x" size="1"></temba-icon>
1936
+ </div>
1937
+ </div>
1938
+ ${this.copiedExpression
1939
+ ? html`<div class="context-toast">
1940
+ Copied
1941
+ <span class="expression">${this.copiedExpression}</span>
1942
+ to the clipboard
1943
+ </div>`
1944
+ : this.toastMessage
1945
+ ? html`<div class="context-toast">${this.toastMessage}</div>`
1946
+ : html``}
1947
+ </div>
1948
+
1949
+ <div
1950
+ class="phone-frame"
1951
+ style="pointer-events: ${this.isVisible ? 'all' : 'none'}"
1952
+ >
1953
+ <div class="phone-top drag-handle">
1954
+ <div class="phone-notch">
1955
+ <div class="dynamic-island"></div>
1956
+ </div>
1957
+ </div>
1958
+ <div class="phone-screen">${this.renderMessages()}</div>
1959
+ <div class="message-input">
1960
+ <button
1961
+ class="attachment-button"
1962
+ @click=${this.handleToggleAttachmentMenu}
1963
+ ?disabled=${this.sprinting}
1964
+ >
1965
+ <temba-icon name="plus" size="1.5"></temba-icon>
1966
+ </button>
1967
+ <input
1968
+ type="text"
1969
+ placeholder="Enter Message"
1970
+ .value=${this.inputValue}
1971
+ @input=${this.handleInput}
1972
+ @keyup=${this.handleKeyUp}
1973
+ ?disabled=${this.sprinting}
1974
+ />
1975
+ <div
1976
+ class="attachment-menu ${this.attachmentMenuOpen ? 'open' : ''}"
1977
+ >
1978
+ <div
1979
+ class="attachment-menu-item"
1980
+ @click=${() => this.handleSendAttachment('image')}
1981
+ >
1982
+ <temba-icon name="attachment_image" size="1.2"></temba-icon>
1983
+ <span>Image</span>
1984
+ </div>
1985
+ <div
1986
+ class="attachment-menu-item"
1987
+ @click=${() => this.handleSendAttachment('video')}
1988
+ >
1989
+ <temba-icon name="attachment_video" size="1.2"></temba-icon>
1990
+ <span>Video</span>
1991
+ </div>
1992
+ <div
1993
+ class="attachment-menu-item"
1994
+ @click=${() => this.handleSendAttachment('audio')}
1995
+ >
1996
+ <temba-icon name="attachment_audio" size="1.2"></temba-icon>
1997
+ <span>Audio</span>
1998
+ </div>
1999
+ <div
2000
+ class="attachment-menu-item"
2001
+ @click=${() => this.handleSendAttachment('location')}
2002
+ >
2003
+ <temba-icon
2004
+ name="attachment_location"
2005
+ size="1.2"
2006
+ ></temba-icon>
2007
+ <span>Location</span>
2008
+ </div>
2009
+ </div>
2010
+ </div>
2011
+ </div>
2012
+ <div class="option-pane">
2013
+ <button class="option-btn" @click=${this.handleClose} title="Close">
2014
+ <temba-icon name="x" size="1.5"></temba-icon>
2015
+ </button>
2016
+ <button
2017
+ class="option-btn ${this.following ? 'active' : ''}"
2018
+ @click=${this.handleToggleFollow}
2019
+ title="${this.following ? 'Following' : 'Follow'}"
2020
+ >
2021
+ <temba-icon name="follow" size="1.5"></temba-icon>
2022
+ </button>
2023
+
2024
+ <button
2025
+ class="option-btn ${this.contextExplorerOpen ? 'active' : ''}"
2026
+ @click=${this.handleToggleContextExplorer}
2027
+ title="Context Explorer"
2028
+ >
2029
+ <temba-icon name="expressions" size="1.5"></temba-icon>
2030
+ </button>
2031
+
2032
+ <button
2033
+ class="option-btn"
2034
+ @click=${this.handleCycleSize}
2035
+ title="Size: ${this.size}"
2036
+ >
2037
+ ${this.size === 'small'
2038
+ ? 'S'
2039
+ : this.size === 'medium'
2040
+ ? 'M'
2041
+ : 'L'}
2042
+ </button>
2043
+
2044
+ <button class="option-btn" @click=${this.handleReset} title="Reset">
2045
+ <temba-icon name="delete" size="1.5"></temba-icon>
2046
+ </button>
2047
+ </div>
2048
+ </div>
2049
+ </temba-floating-window>
2050
+
2051
+ <temba-floating-tab
2052
+ id="phone-tab"
2053
+ icon="simulator"
2054
+ label="Phone Simulator"
2055
+ color="#10b981"
2056
+ .hidden=${this.isVisible}
2057
+ @temba-button-clicked=${this.handleShow}
2058
+ ></temba-floating-tab>
2059
+ `;
2060
+ }
2061
+ }