@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
@@ -109,6 +109,16 @@ export function extractIncompleteMessageState(
109
109
  break
110
110
  }
111
111
 
112
+ case 'escalation_offer':
113
+ // A pending offer leaves the turn unfinished so the reducer holds the
114
+ // card and the resolution chunk can flip it in place. It never sets
115
+ // `agentBusy`: Fae is blocked on the user's decision, and on approval
116
+ // she goes SILENT rather than resuming — there is no work to spin for.
117
+ if (!segment.status || segment.status === 'pending') {
118
+ hasIncompleteState = true
119
+ }
120
+ break
121
+
112
122
  case 'context_compaction':
113
123
  if (segment.status === 'started') {
114
124
  hasIncompleteState = true
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ApprovalBatchSegment,
3
3
  ApprovalRequestSegment,
4
+ EscalationOfferSegment,
4
5
  MessageContent,
5
6
  ToolExecutionSegment,
6
7
  } from '../types'
@@ -106,6 +107,12 @@ function turnRequestKeys(content: MessageContent): Set<string> {
106
107
  } else if (seg.type === 'approval_request') {
107
108
  const id = (seg as ApprovalRequestSegment).data?.requestId
108
109
  if (id) keys.add(id)
110
+ } else if (seg.type === 'escalation_offer') {
111
+ const id = (seg as EscalationOfferSegment).data?.offerId
112
+ if (id) keys.add(id)
113
+ // `ticket_escalated` is deliberately absent: its only id is the ticketId,
114
+ // which is dialog-scoped, so a re-escalated dialog would false-match an
115
+ // older bubble and slice the newest persisted turn out of the thread.
109
116
  } else if (seg.type === 'approval_batch') {
110
117
  const data = (seg as ApprovalBatchSegment).data
111
118
  if (data?.approvalRequestId) keys.add(data.approvalRequestId)
@@ -16,6 +16,9 @@ import type {
16
16
  ApprovalBatchSegment,
17
17
  ApprovalBatchExecutionState,
18
18
  ApprovalResolutionHandler,
19
+ EscalationOfferSegment,
20
+ TicketEscalatedData,
21
+ TicketEscalatedSegment,
19
22
  ContextCompactionSegment,
20
23
  ErrorSegment,
21
24
  PendingApproval,
@@ -35,6 +38,12 @@ export interface AccumulatorCallbacks {
35
38
  * approve/reject signature incl. the boolean failure flag. */
36
39
  onApprove?: ApprovalResolutionHandler
37
40
  onReject?: ApprovalResolutionHandler
41
+ /** Escalation offers resolve through the ticket-escalation mutations, NOT
42
+ * the tool-approval endpoint, so they carry their own pair of handlers.
43
+ * Sharing `onApprove`/`onReject` would POST an offer id to the approval
44
+ * endpoint, which has no record of it. */
45
+ onEscalationApprove?: ApprovalResolutionHandler
46
+ onEscalationReject?: ApprovalResolutionHandler
38
47
  }
39
48
 
40
49
  /**
@@ -432,11 +441,73 @@ export class MessageSegmentAccumulator {
432
441
  }
433
442
 
434
443
  /**
435
- * Update status of an existing approval segment (single or batch).
444
+ * Add a ticket-escalation offer block. Upserts by `offerId` for the same
445
+ * reason `addApprovalBatch` does: the consumer-store replay path feeds
446
+ * `[existing..., new...]` back through `replaySegments`, which would
447
+ * otherwise yield two cards for one offer.
448
+ */
449
+ addEscalationOffer(
450
+ offerId: string,
451
+ text: string,
452
+ origin: string | undefined,
453
+ status: ChatApprovalStatus = 'pending',
454
+ resolvedByName?: string | null,
455
+ ): MessageSegment[] {
456
+ const existingIndex = this.segments.findIndex(
457
+ (s): s is EscalationOfferSegment =>
458
+ s.type === 'escalation_offer' && s.data.offerId === offerId,
459
+ )
460
+ const existing =
461
+ existingIndex !== -1 ? (this.segments[existingIndex] as EscalationOfferSegment) : undefined
462
+
463
+ const segment: EscalationOfferSegment = {
464
+ type: 'escalation_offer',
465
+ // The resolved chunk carries no text/origin — a redelivered offer must
466
+ // not blank the card the PENDING chunk already painted.
467
+ data: { offerId, text: text || existing?.data.text || '', origin: origin ?? existing?.data.origin },
468
+ status,
469
+ resolvedByName: resolvedByName ?? existing?.resolvedByName,
470
+ onApprove: this.callbacks.onEscalationApprove,
471
+ onReject: this.callbacks.onEscalationReject,
472
+ }
473
+
474
+ if (existingIndex !== -1) {
475
+ this.segments[existingIndex] = segment
476
+ return this.getSegments()
477
+ }
478
+
479
+ this.segments.push(segment)
480
+ return this.getSegments()
481
+ }
482
+
483
+ /**
484
+ * Add the handoff receipt. Upserts by `ticketId` so a redelivered block
485
+ * (JetStream catch-up over hydrated history) can't stack a second notice.
486
+ */
487
+ addTicketEscalated(data: TicketEscalatedData): MessageSegment[] {
488
+ const segment: TicketEscalatedSegment = { type: 'ticket_escalated', data }
489
+ const existingIndex = this.segments.findIndex(
490
+ (s) => s.type === 'ticket_escalated' && s.data.ticketId === data.ticketId,
491
+ )
492
+ if (existingIndex !== -1) {
493
+ this.segments[existingIndex] = segment
494
+ return this.getSegments()
495
+ }
496
+ this.segments.push(segment)
497
+ return this.getSegments()
498
+ }
499
+
500
+ /**
501
+ * Update status of an existing approval segment (single, batch, or
502
+ * escalation offer).
436
503
  * `resolvedByName` (when provided) is stamped onto the matching batch segment so the
437
504
  * resolved card shows "by {name}"; omit it to leave any existing value untouched.
438
505
  */
439
- updateApprovalStatus(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null): MessageSegment[] {
506
+ updateApprovalStatus(
507
+ requestId: string,
508
+ status: ChatApprovalStatus,
509
+ resolvedByName?: string | null,
510
+ ): MessageSegment[] {
440
511
  // ONE rule, two containers: `applyApprovalStatusToSegment` is the
441
512
  // same predicate the message-array projection uses
442
513
  // (`projectApprovalResolutionToMessages`) — anchor status flip AND
@@ -571,6 +642,14 @@ export class MessageSegmentAccumulator {
571
642
  )
572
643
  break
573
644
  }
645
+ case 'escalation_offer': {
646
+ const { data, status, resolvedByName } = segment
647
+ this.addEscalationOffer(data.offerId, data.text, data.origin, status, resolvedByName)
648
+ break
649
+ }
650
+ case 'ticket_escalated':
651
+ this.addTicketEscalated(segment.data)
652
+ break
574
653
  case 'error':
575
654
  this.addError(segment.title, segment.details)
576
655
  break
@@ -37,11 +37,12 @@ import {
37
37
  type MessageData,
38
38
  type MessageOwner,
39
39
  } from '../types'
40
- import type { ChatStreamEvent } from '../../../chat-protocol/events'
40
+ import { ESCALATION_STATE, escalationResolvedStatus, type ChatStreamEvent } from '../../../chat-protocol/events'
41
41
  // One normalizer for ask rows, shared with the live decoder — history and the
42
42
  // stream must agree on which options are usable.
43
43
  import { normalizeAskOptions } from '../../../chat-protocol/nats-decoder'
44
44
  import { MessageSegmentAccumulator, createMessageSegmentAccumulator } from './message-segment-accumulator'
45
+ import { applyApprovalStatusToSegment } from '../stream/message-mutations'
45
46
  import { getCommandText } from './tool-call-helpers'
46
47
 
47
48
  function getOwnerDisplayName(owner?: MessageOwner): string {
@@ -199,6 +200,38 @@ export function decodeHistoricalMessageData(data: MessageData): ChatStreamEvent
199
200
  }
200
201
  return null
201
202
 
203
+ case MESSAGE_TYPE.ESCALATION_OFFER: {
204
+ if (!('offerId' in data) || !data.offerId) return null
205
+ if (data.state === ESCALATION_STATE.PENDING) {
206
+ return {
207
+ type: 'escalation-offer',
208
+ offerId: data.offerId,
209
+ text: data.text || '',
210
+ origin: data.origin,
211
+ }
212
+ }
213
+ const status = escalationResolvedStatus(data.state)
214
+ if (!status) return null
215
+ return {
216
+ type: 'escalation-offer-resolved',
217
+ offerId: data.offerId,
218
+ status,
219
+ resolvedByName: data.resolvedByName,
220
+ }
221
+ }
222
+
223
+ case MESSAGE_TYPE.TICKET_ESCALATED:
224
+ if ('ticketId' in data && data.ticketId && data.reason) {
225
+ return {
226
+ type: 'ticket-escalated',
227
+ ticketId: data.ticketId,
228
+ reason: data.reason,
229
+ ticketNumber: data.ticketNumber,
230
+ text: data.text,
231
+ }
232
+ }
233
+ return null
234
+
202
235
  case MESSAGE_TYPE.ERROR:
203
236
  if ('error' in data) {
204
237
  return {
@@ -240,6 +273,18 @@ type EscalatedApprovals = Map<
240
273
  { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
241
274
  >
242
275
 
276
+ /**
277
+ * Terminal escalation-offer resolutions collected while walking history, so
278
+ * they can be applied to ALREADY-FLUSHED bubbles after the walk. The offer
279
+ * row and its resolution row are separated by the user message that caused
280
+ * SUPERSEDED, which puts them in different assistant envelopes — by the time
281
+ * the resolution is read, the accumulator holding the card has been reset.
282
+ */
283
+ type OfferResolutions = Map<
284
+ string,
285
+ { status: ChatApprovalStatus; resolvedByName?: string | null }
286
+ >
287
+
243
288
  /**
244
289
  * Replay one decoded event into the shared per-turn segment kernel with the
245
290
  * HISTORY approval semantics (an omitted `displayApprovalTypes` means
@@ -252,13 +297,43 @@ function applyHistoryEvent(
252
297
  approvalStatuses: Record<string, string>,
253
298
  options: MessageProcessingOptions,
254
299
  escalatedApprovals?: EscalatedApprovals,
300
+ offerResolutions?: OfferResolutions,
255
301
  ): void {
256
302
  // batchApprovalsEnabled is owned by the consumer (oss-tenant chat client /
257
303
  // openframe-frontend tickets). Defaults to ON so consumers that haven't
258
304
  // wired the flag yet get the batch UI; pass `false` to force legacy.
259
- const { displayApprovalTypes, batchApprovalsEnabled = true } = options
305
+ const { displayApprovalTypes, batchApprovalsEnabled = true, escalationOfferStates } = options
260
306
 
261
307
  switch (event.type) {
308
+ case 'escalation-offer':
309
+ // Always a real segment, never the tracked/flushed treatment single
310
+ // approvals get: the offer belongs inline where it was posted.
311
+ accumulator.addEscalationOffer(
312
+ event.offerId,
313
+ event.text,
314
+ event.origin,
315
+ escalationOfferStates?.[event.offerId] ?? 'pending',
316
+ )
317
+ break
318
+
319
+ case 'ticket-escalated':
320
+ accumulator.addTicketEscalated({
321
+ ticketId: event.ticketId,
322
+ ticketNumber: event.ticketNumber,
323
+ reason: event.reason,
324
+ text: event.text,
325
+ })
326
+ break
327
+
328
+ case 'escalation-offer-resolved':
329
+ // Recorded, not applied here: `applyOfferResolutions` runs after every
330
+ // flush and covers the same-bubble case as well as the cross-bubble one.
331
+ offerResolutions?.set(event.offerId, {
332
+ status: event.status,
333
+ resolvedByName: event.resolvedByName,
334
+ })
335
+ break
336
+
262
337
  case 'text-delta':
263
338
  accumulator.appendText(event.text)
264
339
  break
@@ -464,11 +539,20 @@ export function processHistoricalMessages(
464
539
  // opt-in: pass the same explicit list to both.
465
540
  displayApprovalTypes,
466
541
  batchApprovalsEnabled,
542
+ escalationOfferStates,
543
+ onEscalationApprove,
544
+ onEscalationReject,
467
545
  } = options
468
546
 
469
547
  const processedMessages: ProcessedMessage[] = []
470
- const accumulator = createMessageSegmentAccumulator({ onApprove, onReject })
548
+ const accumulator = createMessageSegmentAccumulator({
549
+ onApprove,
550
+ onReject,
551
+ onEscalationApprove,
552
+ onEscalationReject,
553
+ })
471
554
  const escalatedApprovals: EscalatedApprovals = new Map()
555
+ const offerResolutions: OfferResolutions = new Map()
472
556
 
473
557
  let currentAssistantId: string | null = null
474
558
  let currentAssistantTimestamp: Date | null = null
@@ -581,8 +665,9 @@ export function processHistoricalMessages(
581
665
  event,
582
666
  accumulator,
583
667
  approvalStatuses,
584
- { displayApprovalTypes, batchApprovalsEnabled },
668
+ { displayApprovalTypes, batchApprovalsEnabled, escalationOfferStates },
585
669
  escalatedApprovals,
670
+ offerResolutions,
586
671
  )
587
672
  })
588
673
 
@@ -614,12 +699,46 @@ export function processHistoricalMessages(
614
699
  })
615
700
  }
616
701
 
702
+ applyOfferResolutions(processedMessages, offerResolutions)
703
+
617
704
  return {
618
705
  messages: processedMessages,
619
706
  escalatedApprovals: escalatedApprovals
620
707
  }
621
708
  }
622
709
 
710
+ /**
711
+ * Flip escalation-offer cards that were flushed into an EARLIER bubble than
712
+ * their resolution row. Mutates `processedMessages` in place (it is local to
713
+ * the caller and not yet handed out). Uses the same `applyApprovalStatusToSegment`
714
+ * rule as the live projection so the two paths cannot drift.
715
+ */
716
+ function applyOfferResolutions(
717
+ processedMessages: ProcessedMessage[],
718
+ offerResolutions: OfferResolutions,
719
+ ): void {
720
+ if (offerResolutions.size === 0) return
721
+
722
+ processedMessages.forEach((msg, index) => {
723
+ if (!Array.isArray(msg.content)) return
724
+ let changed = false
725
+ const content = msg.content.map((segment) => {
726
+ if (segment.type !== 'escalation_offer') return segment
727
+ const resolution = offerResolutions.get(segment.data.offerId)
728
+ if (!resolution) return segment
729
+ const next = applyApprovalStatusToSegment(
730
+ segment,
731
+ segment.data.offerId,
732
+ resolution.status,
733
+ resolution.resolvedByName,
734
+ )
735
+ if (next !== segment) changed = true
736
+ return next
737
+ })
738
+ if (changed) processedMessages[index] = { ...msg, content }
739
+ })
740
+ }
741
+
623
742
  /**
624
743
  * Extract error messages from historical messages
625
744
  * Returns a separate array of error messages that should be displayed
@@ -4,7 +4,7 @@ import { useSortable } from '@dnd-kit/sortable'
4
4
  import { CSS } from '@dnd-kit/utilities'
5
5
  import Link from '../../../embed-shims/next-link'
6
6
  import * as React from 'react'
7
- import { LaptopIcon, Flag02Icon, MessagesIcon } from '../../icons-v2-generated'
7
+ import { LaptopIcon, Flag02Icon, MessagesIcon, UserCheckIcon } from '../../icons-v2-generated'
8
8
  import { DeletedUserAvatar } from '../../ui/deleted-user-avatar'
9
9
  import { SquareAvatar } from '../../ui/square-avatar'
10
10
  import { Tag } from '../../ui/tag'
@@ -119,6 +119,12 @@ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprov
119
119
  </Tooltip>
120
120
  </TooltipProvider>
121
121
  )}
122
+ {ticket.escalatedByUser && (
123
+ <div className="flex items-center gap-[var(--spacing-system-xxs)] text-h6 text-ods-open-yellow">
124
+ <UserCheckIcon className="size-4 shrink-0" />
125
+ <span className="truncate">Escalated by User</span>
126
+ </div>
127
+ )}
122
128
  {showNewMessage && (
123
129
  <Tag
124
130
  label="New Message"
@@ -36,6 +36,13 @@ export interface BoardTicket {
36
36
  createdAt?: string
37
37
  hasNewMessage?: boolean
38
38
  pendingApproval?: BoardTicketPendingApproval
39
+ /**
40
+ * The client asked for a human and confirmed the handoff, so this ticket
41
+ * reached its lane by escalation rather than an inactivity auto-escalation or
42
+ * an admin takeover. Called out on the card because it changes how urgently a
43
+ * technician should pick it up.
44
+ */
45
+ escalatedByUser?: boolean
39
46
  }
40
47
 
41
48
  export interface BoardColumnDef {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { useEffect, useState } from "react"
5
- import { RemoveScroll } from "react-remove-scroll"
5
+ import { usePreventScroll } from "@react-aria/overlays"
6
6
  import { XmarkIcon } from "../icons-v2-generated"
7
7
  import { cn } from "../../utils/cn"
8
8
 
@@ -142,19 +142,14 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
142
142
  return () => clearTimeout(timeout)
143
143
  }, [isOpen])
144
144
 
145
- // Scroll lock via the SAME library Radix primitives use internally
146
- // (react-remove-scroll is ref-counted and composes with itself). The
147
- // previous react-aria lock FOUGHT the lock a Radix Select opened inside
148
- // the modal added on top — body jumped and the page behind went black
149
- // while the select was open.
145
+ // Shared ref-counted scroll lock (react-aria) — restores prior styles on
146
+ // release instead of clobbering to 'unset'.
147
+ usePreventScroll({ isDisabled: !isOpen })
150
148
 
151
149
  // Escape key (document-level: top-of-stack semantics for modals)
152
150
  useEffect(() => {
153
151
  const handleKeyDown = (event: KeyboardEvent) => {
154
- // A nested Radix layer (Select, DropdownMenu) preventDefaults the
155
- // Escape it consumes — without this check, closing a select inside
156
- // the modal closed the WHOLE modal.
157
- if (event.key === 'Escape' && !event.defaultPrevented) {
152
+ if (event.key === 'Escape') {
158
153
  onClose()
159
154
  }
160
155
  }
@@ -170,7 +165,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
170
165
  const state = isOpen ? "open" : "closed"
171
166
 
172
167
  return (
173
- <RemoveScroll enabled={isOpen} removeScrollBar={false}>
174
168
  <div className="fixed inset-0 z-[1300] flex items-end md:items-center justify-center">
175
169
  <div
176
170
  data-state={state}
@@ -224,7 +218,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
224
218
  </ModalContext.Provider>
225
219
  </div>
226
220
  </div>
227
- </RemoveScroll>
228
221
  )
229
222
  }
230
223
  )
@@ -0,0 +1,86 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2
+ import type React from "react";
3
+
4
+ import { EscalationOfferMessage } from "../components/chat/escalation-offer-message";
5
+
6
+ /**
7
+ * `escalation_offer` message segment — Fae asking the client to confirm a
8
+ * handoff to a human technician (Figma: fae chat › "escalation to admin").
9
+ *
10
+ * Renders THROUGH the client approval card rather than reimplementing it, so
11
+ * it is pixel-identical to `Chat/Client/ApprovalRequestMessage` down to the
12
+ * Approve/Reject labels and the resolved pill. What differs is the wiring:
13
+ * approve/reject call the ticket-escalation GraphQL mutations, never the
14
+ * tool-approval endpoint — which is why it is a distinct segment type.
15
+ *
16
+ * The body copy is backend-fixed; the client never composes it. `origin`
17
+ * (TOOL / MANUAL / a trigger reason) is decoded for telemetry only — every
18
+ * origin renders the same card.
19
+ */
20
+
21
+ const OFFER_TEXT =
22
+ "Having issues with the AI assistant? This ticket can be handed off to a technician. Fae will no longer respond in this conversation. A technician will review the ticket and reply when available.";
23
+
24
+ const baseData = { offerId: "offer-1", text: OFFER_TEXT, origin: "MANUAL" };
25
+
26
+ const plainDecorator = (Story: React.ComponentType) => (
27
+ <div style={{ maxWidth: 600, background: "var(--color-bg)" }} className="p-4">
28
+ <Story />
29
+ </div>
30
+ );
31
+
32
+ const meta = {
33
+ title: "Chat/Client/EscalationOfferMessage",
34
+ component: EscalationOfferMessage,
35
+ tags: ["autodocs"],
36
+ parameters: {
37
+ docs: {
38
+ description: {
39
+ component:
40
+ "Renders an `escalation_offer` segment: the backend-fixed handoff prompt with Approve/Reject, or a resolved status pill. Approve performs the handoff (ticket → Tech Required, Fae goes silent); Reject just records the choice.",
41
+ },
42
+ },
43
+ },
44
+ decorators: [plainDecorator],
45
+ } satisfies Meta<typeof EscalationOfferMessage>;
46
+
47
+ export default meta;
48
+ type Story = StoryObj<typeof meta>;
49
+
50
+ /** Pending — the client has not decided yet. */
51
+ export const Pending: Story = {
52
+ args: {
53
+ data: baseData,
54
+ status: "pending",
55
+ onApprove: () => {},
56
+ onReject: () => {},
57
+ },
58
+ };
59
+
60
+ /** Approved — the handoff ran. Paired in the thread with `TicketEscalatedMessage`. */
61
+ export const ApprovedByUser: Story = {
62
+ args: { data: baseData, status: "approved", resolvedByName: "John Smith" },
63
+ };
64
+
65
+ /** Declined — the client chose to keep working with Fae. */
66
+ export const DeclinedByUser: Story = {
67
+ args: { data: baseData, status: "rejected", resolvedByName: "John Smith" },
68
+ };
69
+
70
+ /**
71
+ * Superseded — the client typed a new message over the pending offer, which
72
+ * the backend treats as declining it. Maps onto the shared `cancelled` status.
73
+ */
74
+ export const Superseded: Story = {
75
+ args: { data: baseData, status: "cancelled", resolvedByName: "John Smith" },
76
+ };
77
+
78
+ /** Raised by Fae's own `escalateToHuman` tool call rather than the header action. */
79
+ export const FromToolCall: Story = {
80
+ args: {
81
+ data: { ...baseData, origin: "TOOL" },
82
+ status: "pending",
83
+ onApprove: () => {},
84
+ onReject: () => {},
85
+ },
86
+ };
@@ -150,6 +150,18 @@ export const WithNewMessage: Story = {
150
150
  },
151
151
  }
152
152
 
153
+ /**
154
+ * The client asked for a human and confirmed the handoff — an amber footer row
155
+ * distinguishing it from an inactivity auto-escalation or an admin takeover,
156
+ * which reach the same lane silently. Driven by the ticket's `escalatedByUser`
157
+ * field, not by a label.
158
+ */
159
+ export const EscalatedByUser: Story = {
160
+ args: {
161
+ ticket: { ...BASE_TICKET, id: 'ticket-escalated', escalatedByUser: true },
162
+ },
163
+ }
164
+
153
165
  /**
154
166
  * Pending CLIENT approval — collapsed grey "Pending client approval" row with a
155
167
  * loader glyph. Expand it to reach the shared approve/reject affordance.
@@ -0,0 +1,98 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2
+ import type React from "react";
3
+
4
+ import { TicketEscalatedMessage } from "../components/chat/ticket-escalated-message";
5
+
6
+ /**
7
+ * `ticket_escalated` message segment — the handoff receipt (Figma:
8
+ * `ai-assistant-info`, type=escalation).
9
+ *
10
+ * A first-class block on the wire, NOT something the client infers from an
11
+ * escalation offer's state, so it also covers the inactivity auto-escalation —
12
+ * a path that raises no offer at all.
13
+ *
14
+ * The body is whatever the backend authored, so the reason it gives stays
15
+ * server-side: the card never branches on `data.reason`, which is why a reason
16
+ * added server-side needs no client change. The body WRAPS rather than
17
+ * truncating as the mock does — see the component for why — so the block is
18
+ * 72px only while the text fits two lines.
19
+ */
20
+
21
+ const baseData = {
22
+ ticketId: "68a1f0c2b3d4e5f6a7b8c9d0",
23
+ ticketNumber: 1002,
24
+ reason: "INACTIVITY",
25
+ text: "Automatically escalated to a human technician because the conversation had no new messages for an extended period.",
26
+ };
27
+
28
+ /** Fixed so the timestamp doesn't churn between snapshots. */
29
+ const TIMESTAMP = new Date("2026-08-07T14:47:00");
30
+
31
+ const plainDecorator = (Story: React.ComponentType) => (
32
+ <div style={{ maxWidth: 600, background: "var(--color-bg)" }} className="p-4">
33
+ <Story />
34
+ </div>
35
+ );
36
+
37
+ const meta = {
38
+ title: "Chat/Client/TicketEscalatedMessage",
39
+ component: TicketEscalatedMessage,
40
+ tags: ["autodocs"],
41
+ parameters: {
42
+ docs: {
43
+ description: {
44
+ component:
45
+ "Renders a `TICKET_ESCALATED` block: a bordered info tile, the fixed 'Handed Off to a Technician' title, the message timestamp, and the backend's explanation.",
46
+ },
47
+ },
48
+ },
49
+ decorators: [plainDecorator],
50
+ } satisfies Meta<typeof TicketEscalatedMessage>;
51
+
52
+ export default meta;
53
+ type Story = StoryObj<typeof meta>;
54
+
55
+ /** The inactivity auto-escalation — the only reason the wire defines today. */
56
+ export const Inactivity: Story = {
57
+ args: { data: baseData, timestamp: TIMESTAMP },
58
+ };
59
+
60
+ /**
61
+ * `text` is nullable on the wire; the card falls back to the design's generic
62
+ * line rather than rendering an empty body.
63
+ */
64
+ export const NoBodyFallback: Story = {
65
+ args: { data: { ...baseData, text: undefined }, timestamp: TIMESTAMP },
66
+ };
67
+
68
+ /** No timestamp — the row collapses to title-only, body unchanged. */
69
+ export const WithoutTimestamp: Story = {
70
+ args: { data: baseData },
71
+ };
72
+
73
+ /** Long explanation — wraps and the block grows; nothing is clipped. */
74
+ export const LongBody: Story = {
75
+ args: {
76
+ data: {
77
+ ...baseData,
78
+ text: "Automatically escalated to a human technician because the conversation had no new messages for an extended period while the ticket was still assigned to the AI assistant, and no diagnostic step produced a resolution.",
79
+ },
80
+ timestamp: TIMESTAMP,
81
+ },
82
+ };
83
+
84
+ /**
85
+ * A reason the client has never seen. The open `TicketEscalationReason` union
86
+ * plus rendering from `text` mean a reason added server-side needs no client
87
+ * change — this story is the regression guard for that.
88
+ */
89
+ export const UnknownReason: Story = {
90
+ args: {
91
+ data: {
92
+ ...baseData,
93
+ reason: "USER_REQUESTED",
94
+ text: "Escalated at the user's request.",
95
+ },
96
+ timestamp: TIMESTAMP,
97
+ },
98
+ };