@flamingo-stack/openframe-frontend-core 0.0.523-1675.4360.1 → 0.0.523

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 (163) hide show
  1. package/dist/chat-protocol/events.d.ts +46 -1
  2. package/dist/chat-protocol/events.d.ts.map +1 -1
  3. package/dist/chat-protocol/index.cjs +57 -0
  4. package/dist/chat-protocol/index.cjs.map +1 -1
  5. package/dist/chat-protocol/index.js +56 -1
  6. package/dist/chat-protocol/index.js.map +1 -1
  7. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  8. package/dist/{chunk-NHCIBEP7.js → chunk-2ALPHSGU.js} +4 -4
  9. package/dist/{chunk-NQ6VBHV3.cjs → chunk-2MPS2QY5.cjs} +5 -5
  10. package/dist/{chunk-NQ6VBHV3.cjs.map → chunk-2MPS2QY5.cjs.map} +1 -1
  11. package/dist/{chunk-GMCBFXM6.cjs → chunk-3OFRFRBW.cjs} +26 -26
  12. package/dist/{chunk-GMCBFXM6.cjs.map → chunk-3OFRFRBW.cjs.map} +1 -1
  13. package/dist/{chunk-LCMKRGJK.js → chunk-43MPUCDL.js} +2 -2
  14. package/dist/{chunk-IE2FOJFA.js → chunk-4DJL34HL.js} +2 -2
  15. package/dist/{chunk-FNMFRVLF.cjs → chunk-6MW6ELZQ.cjs} +37 -37
  16. package/dist/{chunk-FNMFRVLF.cjs.map → chunk-6MW6ELZQ.cjs.map} +1 -1
  17. package/dist/{chunk-37S3DSDE.cjs → chunk-ARCH6RK6.cjs} +4 -4
  18. package/dist/{chunk-37S3DSDE.cjs.map → chunk-ARCH6RK6.cjs.map} +1 -1
  19. package/dist/{chunk-GJLBLXOZ.js → chunk-C7T363HO.js} +2 -2
  20. package/dist/{chunk-ZDAVEGZS.js → chunk-CBD3IHPF.js} +2 -2
  21. package/dist/{chunk-RZRS4ZJO.cjs → chunk-CE4CBCKX.cjs} +7 -7
  22. package/dist/{chunk-RZRS4ZJO.cjs.map → chunk-CE4CBCKX.cjs.map} +1 -1
  23. package/dist/{chunk-JIEDH3RE.js → chunk-E2W2XMX2.js} +2 -2
  24. package/dist/{chunk-A7OYLDMC.cjs → chunk-HM53VV7K.cjs} +1279 -931
  25. package/dist/chunk-HM53VV7K.cjs.map +1 -0
  26. package/dist/{chunk-K2EAMPJA.js → chunk-HYVYAWUO.js} +2 -2
  27. package/dist/{chunk-DBVBYB3T.js → chunk-INTFGXHO.js} +5772 -5424
  28. package/dist/chunk-INTFGXHO.js.map +1 -0
  29. package/dist/{chunk-OZIY4DVQ.cjs → chunk-JBCNOJMX.cjs} +3 -3
  30. package/dist/{chunk-OZIY4DVQ.cjs.map → chunk-JBCNOJMX.cjs.map} +1 -1
  31. package/dist/{chunk-DUSJUG6M.cjs → chunk-KSZPPVXT.cjs} +11 -11
  32. package/dist/{chunk-DUSJUG6M.cjs.map → chunk-KSZPPVXT.cjs.map} +1 -1
  33. package/dist/{chunk-7LXQ5XOQ.js → chunk-LEMU4JMX.js} +2 -2
  34. package/dist/{chunk-IZSLOJCQ.cjs → chunk-M7FGOI4V.cjs} +31 -31
  35. package/dist/{chunk-IZSLOJCQ.cjs.map → chunk-M7FGOI4V.cjs.map} +1 -1
  36. package/dist/{chunk-BU6NFK74.js → chunk-MVYTOP3B.js} +5 -5
  37. package/dist/{chunk-OBYNPIMQ.js → chunk-MYQAF43S.js} +4 -4
  38. package/dist/{chunk-UWD4GPQP.cjs → chunk-OPPGJ4PY.cjs} +87 -87
  39. package/dist/{chunk-UWD4GPQP.cjs.map → chunk-OPPGJ4PY.cjs.map} +1 -1
  40. package/dist/{chunk-Q6FQGEVJ.js → chunk-QCWVGCT3.js} +6 -6
  41. package/dist/{chunk-YBAY6RQR.cjs → chunk-QRYJYZTW.cjs} +61 -61
  42. package/dist/{chunk-YBAY6RQR.cjs.map → chunk-QRYJYZTW.cjs.map} +1 -1
  43. package/dist/{chunk-J544COLB.cjs → chunk-S4BAM7EC.cjs} +9 -9
  44. package/dist/{chunk-J544COLB.cjs.map → chunk-S4BAM7EC.cjs.map} +1 -1
  45. package/dist/{chunk-7QMFZFWJ.cjs → chunk-TH4BQCOD.cjs} +26 -26
  46. package/dist/{chunk-7QMFZFWJ.cjs.map → chunk-TH4BQCOD.cjs.map} +1 -1
  47. package/dist/{chunk-M4VA2S4M.js → chunk-TPIF252F.js} +2 -2
  48. package/dist/{chunk-ORI4OIJR.cjs → chunk-YTLLRLMU.cjs} +14 -14
  49. package/dist/{chunk-ORI4OIJR.cjs.map → chunk-YTLLRLMU.cjs.map} +1 -1
  50. package/dist/{chunk-JEFNDGNP.js → chunk-ZYBBV6CK.js} +2 -2
  51. package/dist/components/case-studies/index.cjs +8 -8
  52. package/dist/components/case-studies/index.js +2 -2
  53. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  54. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  55. package/dist/components/chat/escalation-offer-message.d.ts +26 -0
  56. package/dist/components/chat/escalation-offer-message.d.ts.map +1 -0
  57. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +7 -1
  58. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  59. package/dist/components/chat/index.cjs +8 -2
  60. package/dist/components/chat/index.cjs.map +1 -1
  61. package/dist/components/chat/index.d.ts +2 -0
  62. package/dist/components/chat/index.d.ts.map +1 -1
  63. package/dist/components/chat/index.js +7 -1
  64. package/dist/components/chat/stream/chat-stream-reducer.d.ts +1 -1
  65. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  66. package/dist/components/chat/stream/message-mutations.d.ts +6 -2
  67. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  68. package/dist/components/chat/ticket-escalated-message.d.ts +25 -0
  69. package/dist/components/chat/ticket-escalated-message.d.ts.map +1 -0
  70. package/dist/components/chat/types/api.types.d.ts +22 -4
  71. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  72. package/dist/components/chat/types/message.types.d.ts +74 -2
  73. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  74. package/dist/components/chat/types/processing.types.d.ts +13 -0
  75. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  76. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  77. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  78. package/dist/components/chat/utils/message-segment-accumulator.d.ts +21 -2
  79. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  80. package/dist/components/chat/utils/process-historical-messages.d.ts +1 -1
  81. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  82. package/dist/components/contact/index.cjs +3 -3
  83. package/dist/components/contact/index.js +2 -2
  84. package/dist/components/docs/index.cjs +5 -5
  85. package/dist/components/docs/index.js +4 -4
  86. package/dist/components/embeds/index.cjs +3 -3
  87. package/dist/components/embeds/index.js +2 -2
  88. package/dist/components/faq/index.cjs +3 -3
  89. package/dist/components/faq/index.js +2 -2
  90. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  91. package/dist/components/features/board/types.d.ts +7 -0
  92. package/dist/components/features/board/types.d.ts.map +1 -1
  93. package/dist/components/features/index.cjs +2 -2
  94. package/dist/components/features/index.js +1 -1
  95. package/dist/components/help-center-pages/index.cjs +22 -22
  96. package/dist/components/help-center-pages/index.js +13 -13
  97. package/dist/components/index.cjs +138 -132
  98. package/dist/components/index.cjs.map +1 -1
  99. package/dist/components/index.js +17 -11
  100. package/dist/components/index.js.map +1 -1
  101. package/dist/components/meeting-scheduler/index.cjs +34 -34
  102. package/dist/components/meeting-scheduler/index.js +3 -3
  103. package/dist/components/navigation/index.cjs +2 -2
  104. package/dist/components/navigation/index.js +1 -1
  105. package/dist/components/onboarding-guides/index.cjs +5 -5
  106. package/dist/components/onboarding-guides/index.js +4 -4
  107. package/dist/components/related-content/index.cjs +3 -3
  108. package/dist/components/related-content/index.js +2 -2
  109. package/dist/components/tickets/index.cjs +6 -6
  110. package/dist/components/tickets/index.js +5 -5
  111. package/dist/components/ui/index.cjs +8 -2
  112. package/dist/components/ui/index.cjs.map +1 -1
  113. package/dist/components/ui/index.js +7 -1
  114. package/dist/components/ui/modal-v2.d.ts.map +1 -1
  115. package/dist/index.cjs +8 -2
  116. package/dist/index.cjs.map +1 -1
  117. package/dist/index.js +7 -1
  118. package/dist/utils/index.cjs.map +1 -1
  119. package/dist/utils/index.js.map +1 -1
  120. package/package.json +2 -3
  121. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +29 -0
  122. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +32 -0
  123. package/src/chat-protocol/events.ts +66 -0
  124. package/src/chat-protocol/nats-decoder.ts +47 -0
  125. package/src/components/chat/__tests__/escalation-offer.test.ts +276 -0
  126. package/src/components/chat/chat-message-enhanced.tsx +15 -0
  127. package/src/components/chat/chat-message-list.tsx +3 -1
  128. package/src/components/chat/escalation-offer-message.tsx +49 -0
  129. package/src/components/chat/hooks/.use-jetstream-dialog-subscription.md +11 -4
  130. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +71 -6
  131. package/src/components/chat/index.ts +2 -0
  132. package/src/components/chat/stream/chat-stream-reducer.ts +50 -0
  133. package/src/components/chat/stream/message-mutations.ts +47 -20
  134. package/src/components/chat/ticket-escalated-message.tsx +68 -0
  135. package/src/components/chat/types/api.types.ts +22 -4
  136. package/src/components/chat/types/message.types.ts +86 -1
  137. package/src/components/chat/types/processing.types.ts +13 -0
  138. package/src/components/chat/utils/__tests__/history-merge.test.ts +99 -0
  139. package/src/components/chat/utils/extract-incomplete-message-state.ts +10 -0
  140. package/src/components/chat/utils/history-merge.ts +7 -0
  141. package/src/components/chat/utils/message-segment-accumulator.ts +81 -2
  142. package/src/components/chat/utils/process-historical-messages.ts +123 -4
  143. package/src/components/features/board/ticket-card.tsx +7 -1
  144. package/src/components/features/board/types.ts +7 -0
  145. package/src/components/ui/modal-v2.tsx +5 -12
  146. package/src/stories/EscalationOfferMessage.stories.tsx +86 -0
  147. package/src/stories/TicketCard.stories.tsx +12 -0
  148. package/src/stories/TicketEscalatedMessage.stories.tsx +98 -0
  149. package/dist/chunk-A7OYLDMC.cjs.map +0 -1
  150. package/dist/chunk-DBVBYB3T.js.map +0 -1
  151. /package/dist/{chunk-NHCIBEP7.js.map → chunk-2ALPHSGU.js.map} +0 -0
  152. /package/dist/{chunk-LCMKRGJK.js.map → chunk-43MPUCDL.js.map} +0 -0
  153. /package/dist/{chunk-IE2FOJFA.js.map → chunk-4DJL34HL.js.map} +0 -0
  154. /package/dist/{chunk-GJLBLXOZ.js.map → chunk-C7T363HO.js.map} +0 -0
  155. /package/dist/{chunk-ZDAVEGZS.js.map → chunk-CBD3IHPF.js.map} +0 -0
  156. /package/dist/{chunk-JIEDH3RE.js.map → chunk-E2W2XMX2.js.map} +0 -0
  157. /package/dist/{chunk-K2EAMPJA.js.map → chunk-HYVYAWUO.js.map} +0 -0
  158. /package/dist/{chunk-7LXQ5XOQ.js.map → chunk-LEMU4JMX.js.map} +0 -0
  159. /package/dist/{chunk-BU6NFK74.js.map → chunk-MVYTOP3B.js.map} +0 -0
  160. /package/dist/{chunk-OBYNPIMQ.js.map → chunk-MYQAF43S.js.map} +0 -0
  161. /package/dist/{chunk-Q6FQGEVJ.js.map → chunk-QCWVGCT3.js.map} +0 -0
  162. /package/dist/{chunk-M4VA2S4M.js.map → chunk-TPIF252F.js.map} +0 -0
  163. /package/dist/{chunk-JEFNDGNP.js.map → chunk-ZYBBV6CK.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.523-1675.4360.1",
3
+ "version": "0.0.523",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -393,8 +393,7 @@
393
393
  "tailwindcss-animate": "^1.0.7",
394
394
  "unist-util-visit": "^5.0.0",
395
395
  "use-stick-to-bottom": "^1.1.4",
396
- "vaul": "^1.1.2",
397
- "react-remove-scroll": "^2.7.1"
396
+ "vaul": "^1.1.2"
398
397
  },
399
398
  "devDependencies": {
400
399
  "@biomejs/biome": "2.4.4",
@@ -179,6 +179,26 @@ exports[`decodeNatsChunk — golden corpus > maps the full recorded corpus to no
179
179
  "title": "Agent crashed",
180
180
  "type": "error",
181
181
  },
182
+ "escalation_offer_approved": {
183
+ "offerId": "offer-1",
184
+ "resolvedByName": "John Smith",
185
+ "status": "approved",
186
+ "type": "escalation-offer-resolved",
187
+ },
188
+ "escalation_offer_missing_id": null,
189
+ "escalation_offer_pending": {
190
+ "offerId": "offer-1",
191
+ "origin": "MANUAL",
192
+ "text": "This ticket can be handed off to a technician.",
193
+ "type": "escalation-offer",
194
+ },
195
+ "escalation_offer_superseded": {
196
+ "offerId": "offer-1",
197
+ "resolvedByName": "John Smith",
198
+ "status": "cancelled",
199
+ "type": "escalation-offer-resolved",
200
+ },
201
+ "escalation_offer_unknown_state": null,
182
202
  "executed_tool": {
183
203
  "data": {
184
204
  "integratedToolType": "TACTICAL_RMM",
@@ -307,6 +327,15 @@ exports[`decodeNatsChunk — golden corpus > maps the full recorded corpus to no
307
327
  "type": "thinking-delta",
308
328
  },
309
329
  "thinking_missing_text": null,
330
+ "ticket_escalated": {
331
+ "reason": "INACTIVITY",
332
+ "text": "Automatically escalated to a human technician.",
333
+ "ticketId": "ticket-1",
334
+ "ticketNumber": 1002,
335
+ "type": "ticket-escalated",
336
+ },
337
+ "ticket_escalated_missing_reason": null,
338
+ "ticket_escalated_missing_ticket_id": null,
310
339
  "token_usage": {
311
340
  "contextSize": 180000,
312
341
  "inputTokensSize": 1200,
@@ -161,6 +161,38 @@ const CORPUS: Record<string, unknown> = {
161
161
  command: 'echo hi',
162
162
  },
163
163
 
164
+ escalation_offer_pending: {
165
+ type: 'ESCALATION_OFFER',
166
+ offerId: 'offer-1',
167
+ state: 'PENDING',
168
+ text: 'This ticket can be handed off to a technician.',
169
+ origin: 'MANUAL',
170
+ },
171
+ escalation_offer_approved: {
172
+ type: 'ESCALATION_OFFER',
173
+ offerId: 'offer-1',
174
+ state: 'APPROVED',
175
+ displayName: 'John Smith',
176
+ },
177
+ escalation_offer_superseded: {
178
+ type: 'ESCALATION_OFFER',
179
+ offerId: 'offer-1',
180
+ state: 'SUPERSEDED',
181
+ resolvedByName: 'John Smith',
182
+ },
183
+ escalation_offer_unknown_state: { type: 'ESCALATION_OFFER', offerId: 'offer-1', state: 'WAT' },
184
+ escalation_offer_missing_id: { type: 'ESCALATION_OFFER', state: 'PENDING' },
185
+
186
+ ticket_escalated: {
187
+ type: 'TICKET_ESCALATED',
188
+ ticketId: 'ticket-1',
189
+ ticketNumber: 1002,
190
+ reason: 'INACTIVITY',
191
+ text: 'Automatically escalated to a human technician.',
192
+ },
193
+ ticket_escalated_missing_reason: { type: 'TICKET_ESCALATED', ticketId: 'ticket-1' },
194
+ ticket_escalated_missing_ticket_id: { type: 'TICKET_ESCALATED', reason: 'INACTIVITY' },
195
+
164
196
  approval_result_approved: {
165
197
  type: 'APPROVAL_RESULT',
166
198
  approvalRequestId: 'req-1',
@@ -160,6 +160,69 @@ export interface ApprovalResolvedEvent extends ChatStreamEventBase {
160
160
  willAutoContinue?: boolean
161
161
  }
162
162
 
163
+ /** The client is offered a handoff of this ticket to a human technician
164
+ * (NATS `ESCALATION_OFFER` chunk in state PENDING). Distinct from
165
+ * `approval-request`: it resolves through the ticket-escalation mutations,
166
+ * never the tool-approval flow, and it is raised by four different origins
167
+ * (Fae's own tool call, the client's header button, a deterministic
168
+ * trigger, or a deferred surfacing at turn end) that all render alike. */
169
+ export interface EscalationOfferEvent extends ChatStreamEventBase {
170
+ type: 'escalation-offer'
171
+ offerId: string
172
+ text: string
173
+ origin?: string
174
+ }
175
+
176
+ /** Wire vocabulary of `EscalationOfferData.state`. */
177
+ export const ESCALATION_STATE = {
178
+ PENDING: 'PENDING',
179
+ APPROVED: 'APPROVED',
180
+ DECLINED: 'DECLINED',
181
+ SUPERSEDED: 'SUPERSEDED',
182
+ } as const
183
+
184
+ /** Terminal wire state → the shared `ChatApprovalStatus` vocabulary; `null`
185
+ * for PENDING or anything unrecognized. Shared by BOTH decoders (live
186
+ * chunks and persisted rows) so a state can never mean two things. */
187
+ export function escalationResolvedStatus(
188
+ state: unknown,
189
+ ): EscalationOfferResolvedEvent['status'] | null {
190
+ switch (state) {
191
+ case ESCALATION_STATE.APPROVED:
192
+ return 'approved'
193
+ case ESCALATION_STATE.DECLINED:
194
+ return 'rejected'
195
+ case ESCALATION_STATE.SUPERSEDED:
196
+ return 'cancelled'
197
+ default:
198
+ return null
199
+ }
200
+ }
201
+
202
+ /** An escalation offer reached a terminal state. The wire's SUPERSEDED
203
+ * (the client typed over a pending offer) maps onto `cancelled` so the
204
+ * whole stack keeps ONE status vocabulary (`ChatApprovalStatus`). The
205
+ * resolved chunk carries no text — consumers flip the existing block. */
206
+ export interface EscalationOfferResolvedEvent extends ChatStreamEventBase {
207
+ type: 'escalation-offer-resolved'
208
+ offerId: string
209
+ status: 'approved' | 'rejected' | 'cancelled'
210
+ resolvedByName?: string | null
211
+ }
212
+
213
+ /** The conversation was handed off to a human technician (`TICKET_ESCALATED`).
214
+ * A first-class block rather than something inferred from an offer's state,
215
+ * so paths that raise no offer at all — the inactivity auto-escalation — still
216
+ * produce a receipt. `text` is nullable on the wire; consumers supply the
217
+ * fallback copy. */
218
+ export interface TicketEscalatedEvent extends ChatStreamEventBase {
219
+ type: 'ticket-escalated'
220
+ ticketId: string
221
+ ticketNumber?: number
222
+ reason: string
223
+ text?: string
224
+ }
225
+
163
226
  /** Per-turn metadata. Raw wire values pass through UNVALIDATED — the
164
227
  * consumer replicates the legacy truthiness/typeof gates (so a
165
228
  * malformed frame degrades identically to the pre-SSOT parser). */
@@ -265,6 +328,9 @@ export type ChatStreamEvent =
265
328
  | ToolExecutionEvent
266
329
  | ApprovalRequestEvent
267
330
  | ApprovalResolvedEvent
331
+ | EscalationOfferEvent
332
+ | EscalationOfferResolvedEvent
333
+ | TicketEscalatedEvent
268
334
  | ChatMetadataEvent
269
335
  | UsageEvent
270
336
  | TokenUsageEvent
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { MESSAGE_TYPE } from '../components/chat/types/message.types'
20
20
  import type { AskOptionData } from '../components/chat/types/message.types'
21
+ import { ESCALATION_STATE, escalationResolvedStatus } from './events'
21
22
  import type { ApprovalToolCall, ChatStreamEvent } from './events'
22
23
 
23
24
  /** Minimal structural view of a NATS chunk (see `ChunkData` in
@@ -215,6 +216,52 @@ export function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {
215
216
  }
216
217
  }
217
218
 
219
+ case MESSAGE_TYPE.ESCALATION_OFFER: {
220
+ const offerId = typeof data.offerId === 'string' ? data.offerId : ''
221
+ if (!offerId) return null
222
+ if (data.state === ESCALATION_STATE.PENDING) {
223
+ return {
224
+ type: 'escalation-offer',
225
+ offerId,
226
+ text: typeof data.text === 'string' ? data.text : '',
227
+ origin: typeof data.origin === 'string' ? data.origin : undefined,
228
+ ...seq,
229
+ }
230
+ }
231
+ const status = escalationResolvedStatus(data.state)
232
+ if (!status) return null
233
+ // Same realtime/history field split as APPROVAL_RESULT: the chunk names
234
+ // the resolver `displayName`, the persisted row `resolvedByName`.
235
+ return {
236
+ type: 'escalation-offer-resolved',
237
+ offerId,
238
+ status,
239
+ resolvedByName:
240
+ typeof data.resolvedByName === 'string'
241
+ ? data.resolvedByName
242
+ : typeof data.displayName === 'string'
243
+ ? data.displayName
244
+ : undefined,
245
+ ...seq,
246
+ }
247
+ }
248
+
249
+ case MESSAGE_TYPE.TICKET_ESCALATED: {
250
+ const ticketId = typeof data.ticketId === 'string' ? data.ticketId : ''
251
+ const reason = typeof data.reason === 'string' ? data.reason : ''
252
+ // Both are non-null on the wire; a payload missing either is malformed
253
+ // rather than a variant to render.
254
+ if (!ticketId || !reason) return null
255
+ return {
256
+ type: 'ticket-escalated',
257
+ ticketId,
258
+ reason,
259
+ ticketNumber: typeof data.ticketNumber === 'number' ? data.ticketNumber : undefined,
260
+ text: typeof data.text === 'string' ? data.text : undefined,
261
+ ...seq,
262
+ }
263
+ }
264
+
218
265
  case MESSAGE_TYPE.ERROR:
219
266
  return {
220
267
  type: 'error',
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Ticket-escalation offer: decode → accumulate → resolve, across both the
3
+ * live NATS path and history replay.
4
+ *
5
+ * The two behaviours worth pinning are the ones that were expensive to get
6
+ * right, not the happy path:
7
+ * - an offer arriving with NO preceding MESSAGE_START must APPEND to the
8
+ * trailing bubble, never replace its segments (the backend surfaces
9
+ * deferred offers after the turn ends, so this is the normal shape, and
10
+ * a cumulative emit there wipes the reply the user is reading);
11
+ * - an offer and its resolution are two persisted rows separated by the
12
+ * user message that caused SUPERSEDED, so the flip has to reach a bubble
13
+ * that was already flushed.
14
+ */
15
+
16
+ import { describe, expect, it } from 'vitest'
17
+ import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder'
18
+ import { createChatStreamReducer } from '../stream/chat-stream-reducer'
19
+ import { processHistoricalMessages } from '../utils/process-historical-messages'
20
+ import type { EscalationOfferSegment, HistoricalMessage, MessageSegment } from '../types'
21
+
22
+ const OFFER_ID = 'offer-1'
23
+ const OFFER_TEXT = 'Having issues with the AI assistant? This ticket can be handed off to a technician.'
24
+
25
+ const offerChunk = (streamSeq: number) => ({
26
+ type: 'ESCALATION_OFFER',
27
+ offerId: OFFER_ID,
28
+ state: 'PENDING',
29
+ text: OFFER_TEXT,
30
+ origin: 'MANUAL',
31
+ streamSeq,
32
+ })
33
+
34
+ const resolvedChunk = (state: string, streamSeq: number) => ({
35
+ type: 'ESCALATION_OFFER',
36
+ offerId: OFFER_ID,
37
+ state,
38
+ displayName: 'John Smith',
39
+ streamSeq,
40
+ })
41
+
42
+ const offers = (segments: MessageSegment[] | string | undefined): EscalationOfferSegment[] =>
43
+ Array.isArray(segments)
44
+ ? (segments.filter((s) => s.type === 'escalation_offer') as EscalationOfferSegment[])
45
+ : []
46
+
47
+ const historyRow = (id: string, data: Record<string, unknown>): HistoricalMessage => ({
48
+ id,
49
+ createdAt: '2026-08-06T12:00:00Z',
50
+ owner: { type: 'ASSISTANT' },
51
+ messageData: [data as never],
52
+ })
53
+
54
+ describe('decodeNatsChunk — ESCALATION_OFFER', () => {
55
+ it('decodes the PENDING chunk as an offer', () => {
56
+ expect(decodeNatsChunk(offerChunk(1))).toEqual({
57
+ type: 'escalation-offer',
58
+ offerId: OFFER_ID,
59
+ text: OFFER_TEXT,
60
+ origin: 'MANUAL',
61
+ seq: 1,
62
+ })
63
+ })
64
+
65
+ it('maps the terminal states onto the shared approval-status vocabulary', () => {
66
+ expect(decodeNatsChunk(resolvedChunk('APPROVED', 2))).toMatchObject({ status: 'approved' })
67
+ expect(decodeNatsChunk(resolvedChunk('DECLINED', 3))).toMatchObject({ status: 'rejected' })
68
+ expect(decodeNatsChunk(resolvedChunk('SUPERSEDED', 4))).toMatchObject({ status: 'cancelled' })
69
+ })
70
+
71
+ it('reads the resolver from `displayName` (chunk) or `resolvedByName` (row)', () => {
72
+ expect(decodeNatsChunk(resolvedChunk('APPROVED', 5))).toMatchObject({
73
+ resolvedByName: 'John Smith',
74
+ })
75
+ expect(
76
+ decodeNatsChunk({ ...resolvedChunk('APPROVED', 6), displayName: undefined, resolvedByName: 'Ada' }),
77
+ ).toMatchObject({ resolvedByName: 'Ada' })
78
+ })
79
+
80
+ it('drops malformed chunks instead of emitting a card with no id', () => {
81
+ expect(decodeNatsChunk({ type: 'ESCALATION_OFFER', state: 'PENDING' })).toBeNull()
82
+ expect(decodeNatsChunk({ type: 'ESCALATION_OFFER', offerId: OFFER_ID, state: 'WAT' })).toBeNull()
83
+ })
84
+ })
85
+
86
+ describe('chat stream reducer — escalation offer', () => {
87
+ const trailingSegments = (reducer: ReturnType<typeof createChatStreamReducer>): MessageSegment[] => {
88
+ const { messages } = reducer.state
89
+ return messages[messages.length - 1]?.segments ?? []
90
+ }
91
+
92
+ const feed = (reducer: ReturnType<typeof createChatStreamReducer>, chunks: unknown[]) => {
93
+ for (const chunk of chunks) {
94
+ const event = decodeNatsChunk(chunk)
95
+ if (event) reducer.apply(event)
96
+ }
97
+ }
98
+
99
+ it('appends to the trailing bubble when the offer arrives after MESSAGE_END', () => {
100
+ const reducer = createChatStreamReducer({ transport: 'nats' })
101
+ feed(reducer, [
102
+ { type: 'MESSAGE_START', streamSeq: 1 },
103
+ { type: 'TEXT', text: 'Let me take a look at that.', streamSeq: 2 },
104
+ { type: 'MESSAGE_END', streamSeq: 3 },
105
+ offerChunk(4),
106
+ ])
107
+
108
+ const segments = trailingSegments(reducer)
109
+ // The prose the user was reading survives — this is the whole point.
110
+ expect(segments.find((s: MessageSegment) => s.type === 'text')).toMatchObject({
111
+ text: 'Let me take a look at that.',
112
+ })
113
+ expect(offers(segments)).toHaveLength(1)
114
+ })
115
+
116
+ it('flips the card in place on resolution and stamps the resolver', () => {
117
+ const reducer = createChatStreamReducer({ transport: 'nats' })
118
+ feed(reducer, [
119
+ { type: 'MESSAGE_START', streamSeq: 1 },
120
+ offerChunk(2),
121
+ resolvedChunk('APPROVED', 3),
122
+ ])
123
+
124
+ const rendered = offers(trailingSegments(reducer))
125
+ expect(rendered).toHaveLength(1)
126
+ expect(rendered[0]).toMatchObject({ status: 'approved', resolvedByName: 'John Smith' })
127
+ })
128
+
129
+ it('does not claim the agent is working while an offer is pending', () => {
130
+ const busy: unknown[] = []
131
+ const reducer = createChatStreamReducer({
132
+ transport: 'nats',
133
+ onEffect: (effect) => {
134
+ if (effect.name === 'onAgentBusy') busy.push(effect)
135
+ },
136
+ })
137
+ feed(reducer, [{ type: 'MESSAGE_START', streamSeq: 1 }, offerChunk(2), resolvedChunk('APPROVED', 3)])
138
+ expect(busy).toHaveLength(0)
139
+ })
140
+
141
+ it('is idempotent across a redelivered offer (JetStream catch-up)', () => {
142
+ const reducer = createChatStreamReducer({ transport: 'nats' })
143
+ feed(reducer, [{ type: 'MESSAGE_START', streamSeq: 1 }, offerChunk(2), { type: 'MESSAGE_END', streamSeq: 3 }])
144
+ // A replay re-decodes the same chunk with its original seq; the seq gate
145
+ // drops it, but the upsert must hold even if the gate ever moves.
146
+ const event = decodeNatsChunk({ ...offerChunk(2), streamSeq: 9 })
147
+ if (event) reducer.apply(event)
148
+ expect(offers(trailingSegments(reducer))).toHaveLength(1)
149
+ })
150
+ })
151
+
152
+ describe('processHistoricalMessages — escalation offer', () => {
153
+ it('renders the offer inline rather than deferring it to the sticky flush', () => {
154
+ const { messages } = processHistoricalMessages([
155
+ historyRow('m1', {
156
+ type: 'ESCALATION_OFFER',
157
+ offerId: OFFER_ID,
158
+ state: 'PENDING',
159
+ text: OFFER_TEXT,
160
+ origin: 'MANUAL',
161
+ }),
162
+ ])
163
+ expect(messages).toHaveLength(1)
164
+ expect(offers(messages[0].content)[0]).toMatchObject({ status: 'pending' })
165
+ })
166
+
167
+ it('flips a card that was flushed into an EARLIER bubble than its resolution', () => {
168
+ const { messages } = processHistoricalMessages([
169
+ historyRow('m1', {
170
+ type: 'ESCALATION_OFFER',
171
+ offerId: OFFER_ID,
172
+ state: 'PENDING',
173
+ text: OFFER_TEXT,
174
+ }),
175
+ // The user typing is exactly what produces SUPERSEDED, so this message
176
+ // is not incidental — it is what splits the two rows apart.
177
+ {
178
+ id: 'm2',
179
+ createdAt: '2026-08-06T12:01:00Z',
180
+ owner: { type: 'CLIENT' },
181
+ messageData: [{ type: 'TEXT', text: 'actually, never mind' } as never],
182
+ },
183
+ historyRow('m3', {
184
+ type: 'ESCALATION_OFFER',
185
+ offerId: OFFER_ID,
186
+ state: 'SUPERSEDED',
187
+ resolvedByName: 'John Smith',
188
+ }),
189
+ ])
190
+
191
+ const rendered = messages.flatMap((m) => offers(m.content))
192
+ expect(rendered).toHaveLength(1)
193
+ expect(rendered[0]).toMatchObject({ status: 'cancelled', resolvedByName: 'John Smith' })
194
+ // Card text comes from the PENDING row; the resolution row carries none.
195
+ expect(rendered[0].data.text).toBe(OFFER_TEXT)
196
+ })
197
+
198
+ it('honours the host overlay when the resolution row is not in the fetched pages', () => {
199
+ const { messages } = processHistoricalMessages(
200
+ [
201
+ historyRow('m1', {
202
+ type: 'ESCALATION_OFFER',
203
+ offerId: OFFER_ID,
204
+ state: 'PENDING',
205
+ text: OFFER_TEXT,
206
+ }),
207
+ ],
208
+ { escalationOfferStates: { [OFFER_ID]: 'approved' } },
209
+ )
210
+ expect(messages.flatMap((m) => offers(m.content))[0]).toMatchObject({ status: 'approved' })
211
+ })
212
+ })
213
+
214
+ describe('TICKET_ESCALATED — the handoff receipt', () => {
215
+ const escalated = (over = {}) => ({
216
+ type: 'TICKET_ESCALATED',
217
+ ticketId: 't-1',
218
+ ticketNumber: 1002,
219
+ reason: 'INACTIVITY',
220
+ text: 'Automatically escalated to a human technician because the conversation had no new messages.',
221
+ ...over,
222
+ })
223
+
224
+ const receipts = (segments: MessageSegment[] | string | undefined) =>
225
+ Array.isArray(segments) ? segments.filter((s) => s.type === 'ticket_escalated') : []
226
+
227
+ it('decodes the chunk, keeping the backend copy verbatim', () => {
228
+ expect(decodeNatsChunk({ ...escalated(), streamSeq: 42 })).toEqual({
229
+ type: 'ticket-escalated',
230
+ ticketId: 't-1',
231
+ ticketNumber: 1002,
232
+ reason: 'INACTIVITY',
233
+ text: 'Automatically escalated to a human technician because the conversation had no new messages.',
234
+ seq: 42,
235
+ })
236
+ })
237
+
238
+ it('drops payloads missing a non-null wire field', () => {
239
+ expect(decodeNatsChunk({ type: 'TICKET_ESCALATED', reason: 'INACTIVITY' })).toBeNull()
240
+ expect(decodeNatsChunk({ type: 'TICKET_ESCALATED', ticketId: 't-1' })).toBeNull()
241
+ })
242
+
243
+ it('survives an unknown reason without a client change', () => {
244
+ expect(decodeNatsChunk(escalated({ reason: 'USER_REQUESTED' }))).toMatchObject({
245
+ reason: 'USER_REQUESTED',
246
+ })
247
+ })
248
+
249
+ it('appends to the trailing bubble when it arrives with no turn open', () => {
250
+ // The inactivity auto-escalation fires from a scheduler: no MESSAGE_START
251
+ // precedes it, so a cumulative emit here would wipe the last reply.
252
+ const reducer = createChatStreamReducer({ transport: 'nats' })
253
+ for (const chunk of [
254
+ { type: 'MESSAGE_START', streamSeq: 1 },
255
+ { type: 'TEXT', text: 'Let me look into that.', streamSeq: 2 },
256
+ { type: 'MESSAGE_END', streamSeq: 3 },
257
+ { ...escalated(), streamSeq: 4 },
258
+ ]) {
259
+ const event = decodeNatsChunk(chunk)
260
+ if (event) reducer.apply(event)
261
+ }
262
+ const { messages } = reducer.state
263
+ const segments = messages[messages.length - 1]?.segments ?? []
264
+ expect(segments.find((s: MessageSegment) => s.type === 'text')).toMatchObject({
265
+ text: 'Let me look into that.',
266
+ })
267
+ expect(receipts(segments)).toHaveLength(1)
268
+ })
269
+
270
+ it('renders on history replay with no escalation offer anywhere in the thread', () => {
271
+ const { messages } = processHistoricalMessages([historyRow('m1', escalated())])
272
+ const rendered = messages.flatMap((m) => receipts(m.content))
273
+ expect(rendered).toHaveLength(1)
274
+ expect(rendered[0]).toMatchObject({ data: { ticketId: 't-1', reason: 'INACTIVITY' } })
275
+ })
276
+ })
@@ -8,6 +8,8 @@ import { SquareAvatar } from "../ui/square-avatar"
8
8
  import { ToolExecutionDisplay } from "./tool-execution-display"
9
9
  import { ApprovalRequestMessage } from "./approval-request-message"
10
10
  import { ApprovalBatchMessage } from "./approval-batch-message"
11
+ import { EscalationOfferMessage } from "./escalation-offer-message"
12
+ import { TicketEscalatedMessage } from "./ticket-escalated-message"
11
13
  import { ErrorMessageDisplay } from "./error-message-display"
12
14
  import { ContextCompactionDisplay } from "./context-compaction-display"
13
15
  import { ThinkingDisplay } from "./thinking-display"
@@ -725,6 +727,19 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
725
727
  variant={approvalVariant}
726
728
  />
727
729
  )
730
+ } else if (segment.type === 'escalation_offer') {
731
+ return (
732
+ <EscalationOfferMessage
733
+ key={index}
734
+ data={segment.data}
735
+ status={segment.status}
736
+ resolvedByName={segment.resolvedByName}
737
+ onApprove={segment.onApprove}
738
+ onReject={segment.onReject}
739
+ />
740
+ )
741
+ } else if (segment.type === 'ticket_escalated') {
742
+ return <TicketEscalatedMessage key={index} data={segment.data} timestamp={timestamp} />
728
743
  } else if (segment.type === 'error') {
729
744
  return (
730
745
  <ErrorMessageDisplay
@@ -897,7 +897,9 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
897
897
  const lastSegment = lastSegments?.[lastSegments.length - 1]
898
898
  const isPausedOnApproval =
899
899
  !!lastSegment &&
900
- (lastSegment.type === 'approval_request' || lastSegment.type === 'approval_batch') &&
900
+ (lastSegment.type === 'approval_request' ||
901
+ lastSegment.type === 'approval_batch' ||
902
+ lastSegment.type === 'escalation_offer') &&
901
903
  (lastSegment.status === undefined || lastSegment.status === 'pending')
902
904
  const showStreamingLoader =
903
905
  isTyping &&
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react"
4
+ import { cn } from "../../utils/cn"
5
+ import { ApprovalRequestMessage } from "./approval-request-message"
6
+ import type { EscalationOfferSegment } from "./types/message.types"
7
+
8
+ export interface EscalationOfferMessageProps extends HTMLAttributes<HTMLDivElement> {
9
+ data: EscalationOfferSegment['data']
10
+ status?: EscalationOfferSegment['status']
11
+ resolvedByName?: string | null
12
+ onApprove?: EscalationOfferSegment['onApprove']
13
+ onReject?: EscalationOfferSegment['onReject']
14
+ }
15
+
16
+ /**
17
+ * Ticket-escalation offer block.
18
+ *
19
+ * Renders THROUGH the client approval card rather than reimplementing it:
20
+ * per Figma (fae chat › "escalation to admin") the offer is that exact
21
+ * component, down to the Approve/Reject labels and the resolved
22
+ * "Approved by {name}" pill. Only the wiring differs — approve/reject call
23
+ * the ticket-escalation mutations, never the tool-approval endpoint — which
24
+ * is why this is a distinct segment type with its own handlers.
25
+ *
26
+ * The handoff receipt that follows an approval is NOT rendered here: it is its
27
+ * own `TICKET_ESCALATED` block on the wire, which also covers the inactivity
28
+ * auto-escalation — a path that raises no offer to infer from.
29
+ */
30
+ const EscalationOfferMessage = forwardRef<HTMLDivElement, EscalationOfferMessageProps>(
31
+ ({ className, data, status = 'pending', resolvedByName, onApprove, onReject, ...props }, ref) => (
32
+ <div ref={ref} className={cn("flex flex-col", className)} {...props}>
33
+ <ApprovalRequestMessage
34
+ variant="client"
35
+ // The client card renders `explanation` as its body and never shows
36
+ // `command`; the offer's whole payload is that one backend-fixed line.
37
+ data={{ command: '', explanation: data.text, requestId: data.offerId }}
38
+ status={status}
39
+ resolvedByName={resolvedByName}
40
+ onApprove={onApprove}
41
+ onReject={onReject}
42
+ />
43
+ </div>
44
+ )
45
+ )
46
+
47
+ EscalationOfferMessage.displayName = "EscalationOfferMessage"
48
+
49
+ export { EscalationOfferMessage }
@@ -13,22 +13,29 @@ The single exported hook. Accepts `UseJetStreamDialogSubscriptionOptions` and re
13
13
  |---|---|
14
14
  | `isConnected` | Whether the NATS WebSocket connection is live |
15
15
  | `isSubscribed` | Whether the JetStream consumer is active |
16
- | `reconnectionCount` | Increments whenever the live tail is re-established: a NATS reconnect, a JetStream ordered-consumer recreation, or a resync after the page was hidden. Callers refetch persisted history on every increment |
16
+ | `reconnectionCount` | Increments whenever the live tail is re-established: a NATS reconnect, a JetStream ordered-consumer recreation, a resync after the page was hidden, or a host reporting either via `resyncSignal`. Callers refetch persisted history on every increment; one absence counts once even when two sources report it |
17
17
  | `currentStreamSeq` | Highest stream sequence number observed |
18
18
 
19
+ **Host-supplied resync (`resyncSignal` option):**
20
+
21
+ A counter the embedder bumps when it knows, from outside the page, that the tail may have missed something — an absence the page could not observe (a native shell whose window visibility never reaches `document.visibilityState`), or a write made on the page's behalf while it was away. Any increase is treated exactly like a reconnect. The value at mount is a baseline, rebased on every `dialogId` change so switching to a conversation with a lower count cannot swallow later resyncs.
22
+
19
23
  **Internal refs (stable across renders):**
20
24
 
21
25
  - `clientRef` — shared `NatsClient` instance
22
26
  - `lastRecoveryReportRef` — timestamp floor so a flapping consumer cannot storm callers with refetches
27
+ - `resyncTimerRef` — trailing-edge timer collapsing resync requests within `RESYNC_COALESCE_MS`; cleared on dialog change and unmount so a request never crosses a conversation boundary
28
+ - `lastResyncSignalRef` — baseline for `resyncSignal`, rebased per dialog
23
29
  - `subscriptionRef` — active `JetStreamSubscriptionHandle`
24
30
  - `highestStreamSeqRef` — tracks max stream sequence for gap-free resume
25
31
 
26
32
  **Effect order (declaration order matters):**
27
33
 
28
34
  1. **Connection effect** — acquires/releases the shared NATS client when `enabled` or `wsUrl` changes; drives the `startConnectionLifecycle` which emits status events.
29
- 2. **Dialog reset effect** — resets `highestStreamSeqRef` when `dialogId` changes so a new dialog never inherits a stale start sequence.
30
- 3. **Visibility effect** — after the page has been hidden longer than `RESYNC_AFTER_HIDDEN_MS`, returning to view bumps the counter: a hidden page can stop receiving without the socket ever closing, and nothing else reports that.
31
- 4. **Subscription effect** — creates or recreates the ephemeral consumer when `isConnected`, `dialogId`, `topic`, `streamName`, or the reconnect counter change.
35
+ 2. **Dialog reset effect** — resets `highestStreamSeqRef`, rebases the `resyncSignal` baseline, and drops any pending resync when `dialogId` changes, so a new dialog never inherits a stale start sequence or the previous dialog's refetch.
36
+ 3. **Visibility effect** — after the page has been hidden longer than `RESYNC_AFTER_HIDDEN_MS`, returning to view requests a resync: a hidden page can stop receiving without the socket ever closing, and nothing else reports that.
37
+ 4. **Host-signal effect** — an increase in `resyncSignal` requests a resync on the same path, for absences and out-of-band writes the page cannot see.
38
+ 5. **Subscription effect** — creates or recreates the ephemeral consumer when `isConnected`, `dialogId`, `topic`, `streamName`, or the reconnect counter change.
32
39
 
33
40
  **Deliver policy logic:**
34
41