@flamingo-stack/openframe-frontend-core 0.0.523-1663.4375.1 → 0.0.523-1680.4386.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 (153) 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-NMZ33YWL.js → chunk-2ALPHSGU.js} +4 -4
  9. package/dist/{chunk-KBE7SNFZ.cjs → chunk-2MPS2QY5.cjs} +5 -5
  10. package/dist/{chunk-KBE7SNFZ.cjs.map → chunk-2MPS2QY5.cjs.map} +1 -1
  11. package/dist/{chunk-LJCBSJFU.cjs → chunk-3OFRFRBW.cjs} +26 -26
  12. package/dist/{chunk-LJCBSJFU.cjs.map → chunk-3OFRFRBW.cjs.map} +1 -1
  13. package/dist/{chunk-7EYNWY2N.js → chunk-43MPUCDL.js} +2 -2
  14. package/dist/{chunk-NBANPQ3N.js → chunk-4DJL34HL.js} +2 -2
  15. package/dist/{chunk-PZVYYDFL.cjs → chunk-6MW6ELZQ.cjs} +37 -37
  16. package/dist/{chunk-PZVYYDFL.cjs.map → chunk-6MW6ELZQ.cjs.map} +1 -1
  17. package/dist/{chunk-H5TAFA4H.cjs → chunk-ARCH6RK6.cjs} +4 -4
  18. package/dist/{chunk-H5TAFA4H.cjs.map → chunk-ARCH6RK6.cjs.map} +1 -1
  19. package/dist/{chunk-L4DKYRHG.js → chunk-C7T363HO.js} +2 -2
  20. package/dist/{chunk-OPU5BLBT.js → chunk-CBD3IHPF.js} +2 -2
  21. package/dist/{chunk-HPGBHELQ.cjs → chunk-CE4CBCKX.cjs} +7 -7
  22. package/dist/{chunk-HPGBHELQ.cjs.map → chunk-CE4CBCKX.cjs.map} +1 -1
  23. package/dist/{chunk-7LSK5YFW.js → chunk-E2W2XMX2.js} +2 -2
  24. package/dist/{chunk-VYRP7JOD.cjs → chunk-HM53VV7K.cjs} +1245 -923
  25. package/dist/chunk-HM53VV7K.cjs.map +1 -0
  26. package/dist/{chunk-QBLLF7VS.js → chunk-HYVYAWUO.js} +2 -2
  27. package/dist/{chunk-YJLQQGLH.js → chunk-INTFGXHO.js} +5625 -5303
  28. package/dist/chunk-INTFGXHO.js.map +1 -0
  29. package/dist/{chunk-43QX3P4G.cjs → chunk-JBCNOJMX.cjs} +3 -3
  30. package/dist/{chunk-43QX3P4G.cjs.map → chunk-JBCNOJMX.cjs.map} +1 -1
  31. package/dist/{chunk-ZIIIYUZ5.cjs → chunk-KSZPPVXT.cjs} +11 -11
  32. package/dist/{chunk-ZIIIYUZ5.cjs.map → chunk-KSZPPVXT.cjs.map} +1 -1
  33. package/dist/{chunk-ICWQQQTR.js → chunk-LEMU4JMX.js} +2 -2
  34. package/dist/{chunk-OGXZCZSA.cjs → chunk-M7FGOI4V.cjs} +31 -31
  35. package/dist/{chunk-OGXZCZSA.cjs.map → chunk-M7FGOI4V.cjs.map} +1 -1
  36. package/dist/{chunk-FP6HEAQC.js → chunk-MVYTOP3B.js} +5 -5
  37. package/dist/{chunk-ANKQQTAI.js → chunk-MYQAF43S.js} +4 -4
  38. package/dist/{chunk-J2OPY22M.cjs → chunk-OPPGJ4PY.cjs} +87 -87
  39. package/dist/{chunk-J2OPY22M.cjs.map → chunk-OPPGJ4PY.cjs.map} +1 -1
  40. package/dist/{chunk-7ZIGB34Y.js → chunk-QCWVGCT3.js} +6 -6
  41. package/dist/{chunk-JGYVXVN6.cjs → chunk-QRYJYZTW.cjs} +61 -61
  42. package/dist/{chunk-JGYVXVN6.cjs.map → chunk-QRYJYZTW.cjs.map} +1 -1
  43. package/dist/{chunk-JKUYXCG2.cjs → chunk-S4BAM7EC.cjs} +9 -9
  44. package/dist/{chunk-JKUYXCG2.cjs.map → chunk-S4BAM7EC.cjs.map} +1 -1
  45. package/dist/{chunk-7RIYOJLG.cjs → chunk-TH4BQCOD.cjs} +26 -26
  46. package/dist/{chunk-7RIYOJLG.cjs.map → chunk-TH4BQCOD.cjs.map} +1 -1
  47. package/dist/{chunk-M45XIHZY.js → chunk-TPIF252F.js} +2 -2
  48. package/dist/{chunk-D3PPR2WR.cjs → chunk-YTLLRLMU.cjs} +14 -14
  49. package/dist/{chunk-D3PPR2WR.cjs.map → chunk-YTLLRLMU.cjs.map} +1 -1
  50. package/dist/{chunk-BPBMHS4U.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/index.cjs +6 -2
  58. package/dist/components/chat/index.cjs.map +1 -1
  59. package/dist/components/chat/index.d.ts +2 -0
  60. package/dist/components/chat/index.d.ts.map +1 -1
  61. package/dist/components/chat/index.js +5 -1
  62. package/dist/components/chat/stream/chat-stream-reducer.d.ts +1 -1
  63. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  64. package/dist/components/chat/stream/message-mutations.d.ts +6 -2
  65. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  66. package/dist/components/chat/ticket-escalated-message.d.ts +25 -0
  67. package/dist/components/chat/ticket-escalated-message.d.ts.map +1 -0
  68. package/dist/components/chat/types/message.types.d.ts +74 -2
  69. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  70. package/dist/components/chat/types/processing.types.d.ts +13 -0
  71. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  72. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  73. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  74. package/dist/components/chat/utils/message-segment-accumulator.d.ts +21 -2
  75. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  76. package/dist/components/chat/utils/process-historical-messages.d.ts +1 -1
  77. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  78. package/dist/components/contact/index.cjs +3 -3
  79. package/dist/components/contact/index.js +2 -2
  80. package/dist/components/docs/index.cjs +5 -5
  81. package/dist/components/docs/index.js +4 -4
  82. package/dist/components/embeds/index.cjs +3 -3
  83. package/dist/components/embeds/index.js +2 -2
  84. package/dist/components/faq/index.cjs +3 -3
  85. package/dist/components/faq/index.js +2 -2
  86. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  87. package/dist/components/features/board/types.d.ts +7 -0
  88. package/dist/components/features/board/types.d.ts.map +1 -1
  89. package/dist/components/features/index.cjs +2 -2
  90. package/dist/components/features/index.js +1 -1
  91. package/dist/components/help-center-pages/index.cjs +22 -22
  92. package/dist/components/help-center-pages/index.js +13 -13
  93. package/dist/components/index.cjs +136 -132
  94. package/dist/components/index.cjs.map +1 -1
  95. package/dist/components/index.js +15 -11
  96. package/dist/components/index.js.map +1 -1
  97. package/dist/components/meeting-scheduler/index.cjs +34 -34
  98. package/dist/components/meeting-scheduler/index.js +3 -3
  99. package/dist/components/navigation/index.cjs +2 -2
  100. package/dist/components/navigation/index.js +1 -1
  101. package/dist/components/onboarding-guides/index.cjs +5 -5
  102. package/dist/components/onboarding-guides/index.js +4 -4
  103. package/dist/components/related-content/index.cjs +3 -3
  104. package/dist/components/related-content/index.js +2 -2
  105. package/dist/components/tickets/index.cjs +6 -6
  106. package/dist/components/tickets/index.js +5 -5
  107. package/dist/components/ui/index.cjs +6 -2
  108. package/dist/components/ui/index.cjs.map +1 -1
  109. package/dist/components/ui/index.js +5 -1
  110. package/dist/index.cjs +6 -2
  111. package/dist/index.cjs.map +1 -1
  112. package/dist/index.js +5 -1
  113. package/dist/utils/index.cjs.map +1 -1
  114. package/dist/utils/index.js.map +1 -1
  115. package/package.json +1 -1
  116. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +29 -0
  117. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +32 -0
  118. package/src/chat-protocol/events.ts +66 -0
  119. package/src/chat-protocol/nats-decoder.ts +47 -0
  120. package/src/components/chat/__tests__/escalation-offer.test.ts +276 -0
  121. package/src/components/chat/chat-message-enhanced.tsx +15 -0
  122. package/src/components/chat/chat-message-list.tsx +3 -1
  123. package/src/components/chat/escalation-offer-message.tsx +49 -0
  124. package/src/components/chat/index.ts +2 -0
  125. package/src/components/chat/stream/chat-stream-reducer.ts +50 -0
  126. package/src/components/chat/stream/message-mutations.ts +47 -20
  127. package/src/components/chat/ticket-escalated-message.tsx +68 -0
  128. package/src/components/chat/types/message.types.ts +86 -1
  129. package/src/components/chat/types/processing.types.ts +13 -0
  130. package/src/components/chat/utils/extract-incomplete-message-state.ts +10 -0
  131. package/src/components/chat/utils/history-merge.ts +7 -0
  132. package/src/components/chat/utils/message-segment-accumulator.ts +81 -2
  133. package/src/components/chat/utils/process-historical-messages.ts +123 -4
  134. package/src/components/features/board/ticket-card.tsx +7 -1
  135. package/src/components/features/board/types.ts +7 -0
  136. package/src/stories/EscalationOfferMessage.stories.tsx +86 -0
  137. package/src/stories/TicketCard.stories.tsx +12 -0
  138. package/src/stories/TicketEscalatedMessage.stories.tsx +98 -0
  139. package/dist/chunk-VYRP7JOD.cjs.map +0 -1
  140. package/dist/chunk-YJLQQGLH.js.map +0 -1
  141. /package/dist/{chunk-NMZ33YWL.js.map → chunk-2ALPHSGU.js.map} +0 -0
  142. /package/dist/{chunk-7EYNWY2N.js.map → chunk-43MPUCDL.js.map} +0 -0
  143. /package/dist/{chunk-NBANPQ3N.js.map → chunk-4DJL34HL.js.map} +0 -0
  144. /package/dist/{chunk-L4DKYRHG.js.map → chunk-C7T363HO.js.map} +0 -0
  145. /package/dist/{chunk-OPU5BLBT.js.map → chunk-CBD3IHPF.js.map} +0 -0
  146. /package/dist/{chunk-7LSK5YFW.js.map → chunk-E2W2XMX2.js.map} +0 -0
  147. /package/dist/{chunk-QBLLF7VS.js.map → chunk-HYVYAWUO.js.map} +0 -0
  148. /package/dist/{chunk-ICWQQQTR.js.map → chunk-LEMU4JMX.js.map} +0 -0
  149. /package/dist/{chunk-FP6HEAQC.js.map → chunk-MVYTOP3B.js.map} +0 -0
  150. /package/dist/{chunk-ANKQQTAI.js.map → chunk-MYQAF43S.js.map} +0 -0
  151. /package/dist/{chunk-7ZIGB34Y.js.map → chunk-QCWVGCT3.js.map} +0 -0
  152. /package/dist/{chunk-M45XIHZY.js.map → chunk-TPIF252F.js.map} +0 -0
  153. /package/dist/{chunk-BPBMHS4U.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-1663.4375.1",
3
+ "version": "0.0.523-1680.4386.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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 }
@@ -3,6 +3,8 @@
3
3
  // Chat components exports
4
4
  export * from './approval-request-message'
5
5
  export * from './approval-batch-message'
6
+ export * from './escalation-offer-message'
7
+ export * from './ticket-escalated-message'
6
8
  export * from './context-compaction-display'
7
9
  export * from './expand-chevron'
8
10
  export * from './thinking-display'
@@ -197,6 +197,7 @@ export interface ChatReducerEffect {
197
197
  | 'onEscalatedApproval'
198
198
  | 'onEscalatedApprovalResult'
199
199
  | 'onApprovalResolved'
200
+ | 'onEscalationOfferResolved'
200
201
  | 'onToolExecuted'
201
202
  | 'onAgentBusy'
202
203
  | 'onDialogClosed'
@@ -1275,6 +1276,55 @@ export function createChatStreamReducer(
1275
1276
  break
1276
1277
  }
1277
1278
 
1279
+ case 'escalation-offer': {
1280
+ // MUST route through `applyAccumulated`. The block reaches a client
1281
+ // in three shapes — inline in Fae's live turn (tool origin), or with
1282
+ // NO preceding MESSAGE_START when the backend surfaces a deferred
1283
+ // offer at turn end or honours the header button while idle. In the
1284
+ // latter shapes a cumulative emit would replace the trailing bubble's
1285
+ // segments with the lone card, wiping the reply the user is reading.
1286
+ const status = (approvalStatuses[event.offerId] || 'pending') as ChatApprovalStatus
1287
+ const before = accumulator.getSegments().length
1288
+ const segments = accumulator.addEscalationOffer(
1289
+ event.offerId,
1290
+ event.text,
1291
+ event.origin,
1292
+ status,
1293
+ )
1294
+ applyAccumulated(before, segments)
1295
+ break
1296
+ }
1297
+
1298
+ case 'escalation-offer-resolved': {
1299
+ const { offerId, status, resolvedByName: offerResolvedBy } = event
1300
+ // Approving hands the ticket to a human; Fae goes silent rather than
1301
+ // resuming, so — unlike an approved command — there is no work to
1302
+ // signal and the busy lock stays untouched.
1303
+ accumulator.updateApprovalStatus(offerId, status, offerResolvedBy)
1304
+ setMessagesInternal(
1305
+ projectApprovalResolutionToMessages(messages, offerId, status, offerResolvedBy),
1306
+ )
1307
+ mirrorApprovalStatus(offerId, status)
1308
+ emit('onEscalationOfferResolved', offerId, status, offerResolvedBy)
1309
+ break
1310
+ }
1311
+
1312
+ case 'ticket-escalated': {
1313
+ // Same `applyAccumulated` routing as the offer, and for the same
1314
+ // reason: the inactivity auto-escalation fires from a scheduler with
1315
+ // no turn open at all, so this block routinely arrives with no
1316
+ // preceding MESSAGE_START.
1317
+ const before = accumulator.getSegments().length
1318
+ const segments = accumulator.addTicketEscalated({
1319
+ ticketId: event.ticketId,
1320
+ ticketNumber: event.ticketNumber,
1321
+ reason: event.reason,
1322
+ text: event.text,
1323
+ })
1324
+ applyAccumulated(before, segments)
1325
+ break
1326
+ }
1327
+
1278
1328
  case 'error': {
1279
1329
  let message: string | undefined
1280
1330
  if (event.details) {