@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
@@ -75,11 +75,37 @@ export function updateTrailingAssistant(
75
75
  return [...prev.slice(0, -1), { ...last, segments }]
76
76
  }
77
77
 
78
+ /**
79
+ * Identity of a block that must upsert rather than stack when re-appended,
80
+ * namespaced so two kinds can never collide. `null` for segments that coalesce
81
+ * by adjacency (text/thinking/guide) or are pushed raw. Legacy approval cards
82
+ * share one requestId across an unfolded batch, hence the command in the key.
83
+ *
84
+ * `ticket_escalated` keys on its dialog-scoped ticketId, which is safe HERE
85
+ * (two receipts inside one trailing bubble can only be a redelivery) but is NOT
86
+ * a turn identity — see the deliberate omissions in `turnRequestKeys` and the
87
+ * host's thread-global dedupe.
88
+ */
89
+ function upsertKey(seg: MessageSegment): string | null {
90
+ switch (seg.type) {
91
+ case 'approval_batch':
92
+ return `batch:${seg.data.approvalRequestId}`
93
+ case 'approval_request':
94
+ return `req:${seg.data.requestId}:${seg.data.command}`
95
+ case 'escalation_offer':
96
+ return `offer:${seg.data.offerId}`
97
+ case 'ticket_escalated':
98
+ return `escalated:${seg.data.ticketId}`
99
+ default:
100
+ return null
101
+ }
102
+ }
103
+
78
104
  /**
79
105
  * Append-mode counterpart of `updateTrailingAssistant` for post-MESSAGE_END
80
106
  * continuation fragments (`SegmentsUpdateMetadata.append`). Coalesces
81
- * trailing fragments of the same type, mirroring the accumulator; approval
82
- * deltas upsert by request id so a replayed emit stays idempotent.
107
+ * trailing fragments of the same type, mirroring the accumulator; block deltas
108
+ * upsert by identity (see `upsertKey`) so a replayed emit stays idempotent.
83
109
  */
84
110
  export function appendToTrailingAssistant(
85
111
  prev: UnifiedChatMessage[],
@@ -96,30 +122,18 @@ export function appendToTrailingAssistant(
96
122
  const merged = [...(last.segments ?? [])]
97
123
  for (const seg of segments) {
98
124
  const tail = merged[merged.length - 1]
125
+ const key = upsertKey(seg)
99
126
  if (seg.type === 'text' && tail?.type === 'text') {
100
127
  merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text }
101
128
  } else if (seg.type === 'thinking' && tail?.type === 'thinking') {
102
129
  merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text }
103
130
  } else if (seg.type === 'guide' && tail?.type === 'guide') {
104
131
  merged[merged.length - 1] = { type: 'guide', text: tail.text + seg.text }
105
- } else if (seg.type === 'approval_batch') {
106
- // Approval deltas must be IDEMPOTENT: the escalated-result emit can be
107
- // seen twice (live + catch-up replay over hydrated history), so upsert
108
- // by request id instead of raw-appending a duplicate card.
109
- const idx = merged.findIndex(
110
- (m) => m.type === 'approval_batch' && m.data.approvalRequestId === seg.data.approvalRequestId,
111
- )
112
- if (idx !== -1) merged[idx] = seg
113
- else merged.push(seg)
114
- } else if (seg.type === 'approval_request') {
115
- // Legacy cards share one requestId across an unfolded batch — key on
116
- // (requestId, command) so each tool's card upserts its own twin.
117
- const idx = merged.findIndex(
118
- (m) =>
119
- m.type === 'approval_request' &&
120
- m.data.requestId === seg.data.requestId &&
121
- m.data.command === seg.data.command,
122
- )
132
+ } else if (key) {
133
+ // Block deltas must be IDEMPOTENT: an emit can be seen twice (live plus
134
+ // the JetStream catch-up replay over hydrated history), so upsert on the
135
+ // block's identity instead of raw-appending a duplicate card.
136
+ const idx = merged.findIndex((m) => upsertKey(m) === key)
123
137
  if (idx !== -1) merged[idx] = seg
124
138
  else merged.push(seg)
125
139
  } else {
@@ -177,6 +191,10 @@ function mapSegments(
177
191
  * - `approval_batch` ROW (`toolCalls[].toolExecutionRequestId`) →
178
192
  * tick that row's execution (check on approved, cross otherwise)
179
193
  * without touching the batch status.
194
+ * - `escalation_offer` matched on `data.offerId` → status flip +
195
+ * resolver stamp. Escalation offers are backed by the SAME
196
+ * `ToolApprovalRequest` collection as command approvals, so the two id
197
+ * spaces are one and a cross-match is impossible.
180
198
  * Returns the SAME reference when nothing matched or changed.
181
199
  */
182
200
  export function applyApprovalStatusToSegment(
@@ -188,6 +206,15 @@ export function applyApprovalStatusToSegment(
188
206
  if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
189
207
  return { ...s, status }
190
208
  }
209
+ if (s.type === 'escalation_offer') {
210
+ if (s.data.offerId !== requestId) return s
211
+ const nextResolvedBy = resolvedByName ?? s.resolvedByName
212
+ // Status alone is not enough to early-out: the host overlay flips the
213
+ // status first and the persisted row supplies the resolver name after, so
214
+ // bailing on an unchanged status drops the "by {name}" stamp.
215
+ if (s.status === status && nextResolvedBy === s.resolvedByName) return s
216
+ return { ...s, status, resolvedByName: nextResolvedBy }
217
+ }
191
218
  if (s.type !== 'approval_batch') return s
192
219
  const isAnchor = s.data.approvalRequestId === requestId
193
220
  const hasRow = s.data.toolCalls?.some((c) => c.toolExecutionRequestId === requestId)
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react"
4
+ import { cn } from "../../utils/cn"
5
+ import { formatTime } from "../../utils/format-date"
6
+ import { InfoCircleIcon } from "../icons-v2-generated"
7
+ import type { TicketEscalatedSegment } from "./types/message.types"
8
+
9
+ const TITLE = "Handed Off to a Technician"
10
+
11
+ /** Shown when the wire omits `text` (the field is nullable). */
12
+ const DEFAULT_BODY = "A technician will reply when available."
13
+
14
+ export interface TicketEscalatedMessageProps extends HTMLAttributes<HTMLDivElement> {
15
+ data: TicketEscalatedSegment['data']
16
+ /** Timestamp of the message row this block came in on. */
17
+ timestamp?: Date
18
+ }
19
+
20
+ /**
21
+ * Handoff receipt — the conversation now belongs to a human technician.
22
+ * Figma `ai-assistant-info` (type=escalation).
23
+ *
24
+ * The body is whatever the backend authored, so the reason it gives stays
25
+ * server-side; the client never composes an explanation and never branches on
26
+ * `data.reason`, which is why a reason added server-side needs no change here.
27
+ *
28
+ * That body WRAPS, deliberately diverging from the mock's single-line ellipsis:
29
+ * the mock's 72px comes from its placeholder copy fitting one line, while the
30
+ * real wire text is a full sentence, and clipping the only explanation of why
31
+ * the chat was handed off is not recoverable on touch. The title still
32
+ * truncates — it is a constant, and holding one line keeps the timestamp from
33
+ * being pushed out of the row at narrow widths.
34
+ */
35
+ const TicketEscalatedMessage = forwardRef<HTMLDivElement, TicketEscalatedMessageProps>(
36
+ ({ className, data, timestamp, ...props }, ref) => {
37
+ const body = data.text?.trim() || DEFAULT_BODY
38
+ return (
39
+ <div
40
+ ref={ref}
41
+ className={cn(
42
+ "mb-[var(--spacing-system-xsf)] flex items-start gap-[var(--spacing-system-s)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-s)]",
43
+ className,
44
+ )}
45
+ {...props}
46
+ >
47
+ <div className="flex size-12 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-bg">
48
+ <InfoCircleIcon className="size-6 text-ods-flamingo-pink" />
49
+ </div>
50
+ <div className="flex min-w-0 flex-1 flex-col">
51
+ <div className="flex w-full items-start">
52
+ <p className="text-h4 min-w-0 flex-1 truncate text-ods-text-primary" title={TITLE}>
53
+ {TITLE}
54
+ </p>
55
+ {timestamp && (
56
+ <span className="text-h6 shrink-0 text-right text-ods-text-secondary">{formatTime(timestamp)}</span>
57
+ )}
58
+ </div>
59
+ <p className="text-h4 w-full break-words text-ods-text-secondary">{body}</p>
60
+ </div>
61
+ </div>
62
+ )
63
+ },
64
+ )
65
+
66
+ TicketEscalatedMessage.displayName = "TicketEscalatedMessage"
67
+
68
+ export { TicketEscalatedMessage }
@@ -16,6 +16,8 @@ export const MESSAGE_TYPE = {
16
16
  EXECUTED_TOOL: 'EXECUTED_TOOL',
17
17
  APPROVAL_REQUEST: 'APPROVAL_REQUEST',
18
18
  APPROVAL_RESULT: 'APPROVAL_RESULT',
19
+ ESCALATION_OFFER: 'ESCALATION_OFFER',
20
+ TICKET_ESCALATED: 'TICKET_ESCALATED',
19
21
  ERROR: 'ERROR',
20
22
  MESSAGE_START: 'MESSAGE_START',
21
23
  MESSAGE_END: 'MESSAGE_END',
@@ -121,6 +123,35 @@ export interface ApprovalResultData {
121
123
  resolvedByName?: string | null
122
124
  }
123
125
 
126
+ // ========== Escalation Offer Types ==========
127
+
128
+ export interface EscalationOfferData {
129
+ offerId: string
130
+ /** Backend-fixed card copy; the client never composes it. */
131
+ text: string
132
+ /** Why the offer was raised (`TOOL`, `MANUAL`, a trigger reason). Carried
133
+ * for telemetry — every origin renders the same card. */
134
+ origin?: string
135
+ }
136
+
137
+ // ========== Ticket Escalated Types ==========
138
+
139
+ /** Open union: the wire currently defines only `INACTIVITY`, and the block
140
+ * renders from `text` rather than branching on this, so a reason added
141
+ * server-side needs no client change. */
142
+ export type TicketEscalationReason = 'INACTIVITY' | (string & {})
143
+
144
+ /** The handoff receipt — a real block on the wire, not something the client
145
+ * infers from an escalation offer's state. */
146
+ export interface TicketEscalatedData {
147
+ ticketId: string
148
+ ticketNumber?: number
149
+ reason: TicketEscalationReason
150
+ /** Backend-authored explanation; the card falls back to generic copy when
151
+ * the wire omits it (the field is nullable). */
152
+ text?: string
153
+ }
154
+
124
155
  /**
125
156
  * Single tool call inside a batch approval request.
126
157
  * Mirrors backend PendingToolCallDto.
@@ -239,6 +270,35 @@ export type ApprovalBatchSegment = {
239
270
  onReject?: ApprovalResolutionHandler
240
271
  }
241
272
 
273
+ /**
274
+ * Ticket-escalation offer block. Visually the client approval card (same
275
+ * Figma component), but a SEPARATE segment type on purpose: it resolves
276
+ * through the ticket-escalation GraphQL mutations rather than the tool
277
+ * approval endpoint, and hosts that hide pending tool approvals from the
278
+ * thread must still render this one inline.
279
+ *
280
+ * `cancelled` is the wire's SUPERSEDED — the client typed over the offer.
281
+ */
282
+ export type EscalationOfferSegment = {
283
+ type: 'escalation_offer'
284
+ data: EscalationOfferData
285
+ status?: ChatApprovalStatus
286
+ resolvedByName?: string | null
287
+ onApprove?: ApprovalResolutionHandler
288
+ onReject?: ApprovalResolutionHandler
289
+ }
290
+
291
+ /**
292
+ * The conversation was handed off to a human technician. Decoded from the
293
+ * `TICKET_ESCALATED` block, so it appears for every escalation path the
294
+ * backend emits it for — including the inactivity auto-escalation, which
295
+ * raises no offer and therefore has no offer state to infer from.
296
+ */
297
+ export type TicketEscalatedSegment = {
298
+ type: 'ticket_escalated'
299
+ data: TicketEscalatedData
300
+ }
301
+
242
302
  export type ErrorSegment = {
243
303
  type: 'error'
244
304
  title: string
@@ -251,7 +311,7 @@ export type ContextCompactionSegment = {
251
311
  summary?: string
252
312
  }
253
313
 
254
- export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | AskSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | ErrorSegment | ContextCompactionSegment
314
+ export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | AskSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | EscalationOfferSegment | TicketEscalatedSegment | ErrorSegment | ContextCompactionSegment
255
315
 
256
316
  export type MessageContent = string | MessageSegment[]
257
317
 
@@ -329,6 +389,29 @@ export interface ApprovalResultMessageData extends MessageDataBase {
329
389
  resolvedByName?: string | null
330
390
  }
331
391
 
392
+ /**
393
+ * Persisted escalation-offer row. The PENDING row carries `text`/`origin`;
394
+ * the resolution is a SECOND row with the same `offerId`, state
395
+ * APPROVED/DECLINED/SUPERSEDED and no text — matched on replay by id.
396
+ */
397
+ export interface EscalationOfferMessageData extends MessageDataBase {
398
+ type: 'ESCALATION_OFFER'
399
+ offerId?: string
400
+ state?: string
401
+ text?: string
402
+ origin?: string
403
+ resolvedByUserId?: string | null
404
+ resolvedByName?: string | null
405
+ }
406
+
407
+ export interface TicketEscalatedMessageData extends MessageDataBase {
408
+ type: 'TICKET_ESCALATED'
409
+ ticketId?: string
410
+ ticketNumber?: number
411
+ reason?: TicketEscalationReason
412
+ text?: string
413
+ }
414
+
332
415
  export interface ErrorMessageData extends MessageDataBase {
333
416
  type: 'ERROR'
334
417
  error?: string
@@ -373,6 +456,8 @@ export type MessageData =
373
456
  | ExecutedToolMessageData
374
457
  | ApprovalRequestMessageData
375
458
  | ApprovalResultMessageData
459
+ | EscalationOfferMessageData
460
+ | TicketEscalatedMessageData
376
461
  | ErrorMessageData
377
462
  | AIMetadataMessageData
378
463
  | SystemMessageData
@@ -47,6 +47,19 @@ export interface MessageProcessingOptions {
47
47
  chatTypeFilter?: string
48
48
  /** Map of approval statuses by request ID */
49
49
  approvalStatuses?: Record<string, ChatApprovalStatus>
50
+ /**
51
+ * Terminal escalation-offer states by offer id, from live chunks the host
52
+ * has already seen. Needed because an offer and its resolution are two
53
+ * separate persisted rows: SUPERSEDED is published *because* the client
54
+ * typed, so a user message always separates them and the two rows land in
55
+ * different assistant envelopes.
56
+ */
57
+ escalationOfferStates?: Record<string, ChatApprovalStatus>
58
+ /** Escalation approve handler; distinct from `onApprove` because offers
59
+ * resolve through the ticket-escalation mutations. */
60
+ onEscalationApprove?: (offerId?: string) => Promise<void> | void
61
+ /** Escalation reject handler. See `onEscalationApprove`. */
62
+ onEscalationReject?: (offerId?: string) => Promise<void> | void
50
63
  /** Approval types to display directly (others get escalated) - defaults to all types */
51
64
  displayApprovalTypes?: string[]
52
65
  /**
@@ -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