@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
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useRef, useState } from 'react'
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
4
  import {
5
5
  acquireClient as acquireSharedClient,
6
6
  releaseClient as releaseSharedClient,
@@ -20,7 +20,24 @@ const DEFAULT_STREAM_NAME = 'CHAT_CHUNKS'
20
20
  * absence worth resyncing for. Alt-tabbing must not churn the consumer; a
21
21
  * window that sat in the tray must not come back showing a stale conversation.
22
22
  */
23
- const RESYNC_AFTER_HIDDEN_MS = 10_000
23
+ export const RESYNC_AFTER_HIDDEN_MS = 10_000
24
+ /**
25
+ * How long resync requests are gathered before one is acted on.
26
+ *
27
+ * A single reveal can be reported twice: the page sees `visibilitychange`, and
28
+ * a host that knows the window was away reports it too (`resyncSignal`),
29
+ * neither able to tell whether the other noticed. Both land within a frame or
30
+ * two of each other, being driven by the same OS event, and acting on both
31
+ * rebuilds the consumer and refetches history twice for one reveal.
32
+ *
33
+ * Gathered on the TRAILING edge, so a request is deferred and never dropped.
34
+ * Two reports of one reveal collapse into one rebuild; two genuinely different
35
+ * signals — a notification reply landing just before the user opens the window
36
+ * — also collapse into one, which is correct, because a single refetch after
37
+ * the last of them covers both. The cost is a second of latency on a path whose
38
+ * next step is a network round trip.
39
+ */
40
+ const RESYNC_COALESCE_MS = 1_000
24
41
  /**
25
42
  * Floor between two reported recoveries. Every recovery costs the caller a
26
43
  * history refetch, and a consumer that cannot hold its sequence — gap, reset,
@@ -60,6 +77,7 @@ export function useJetStreamDialogSubscription({
60
77
  clientConfig = {},
61
78
  reconnectionBackoff,
62
79
  inactiveThresholdMs,
80
+ resyncSignal = 0,
63
81
  }: UseJetStreamDialogSubscriptionOptions): UseJetStreamDialogSubscriptionReturn {
64
82
  const [isConnected, setIsConnected] = useState(false)
65
83
  const [isSubscribed, setIsSubscribed] = useState(false)
@@ -73,6 +91,8 @@ export function useJetStreamDialogSubscription({
73
91
  const [currentStreamSeq, setCurrentStreamSeq] = useState<number | null>(null)
74
92
 
75
93
  const lastRecoveryReportRef = useRef(0)
94
+ const resyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
95
+ const lastResyncSignalRef = useRef(resyncSignal)
76
96
  const clientRef = useRef<NatsClient | null>(null)
77
97
  const subscriptionRef = useRef<JetStreamSubscriptionHandle | null>(null)
78
98
  const highestStreamSeqRef = useRef<number | null>(null)
@@ -245,7 +265,26 @@ export function useJetStreamDialogSubscription({
245
265
  // ignoring optStartSeq).
246
266
  useEffect(() => {
247
267
  highestStreamSeqRef.current = null
268
+ // A host's signal counts per dialog (it can carry a per-conversation term),
269
+ // so the baseline has to move with the dialog or a switch to one with a
270
+ // LOWER count would swallow every later resync for it.
271
+ lastResyncSignalRef.current = resyncSignal
248
272
  setCurrentStreamSeq(null)
273
+ // A resync gathered for the previous dialog must not land on this one: the
274
+ // counter says nothing about which conversation it meant, so callers would
275
+ // refetch the wrong one. The dialog change supersedes it — the new dialog
276
+ // loads its own history regardless. As cleanup, so it covers unmount too,
277
+ // which is the same rule.
278
+ return () => {
279
+ if (resyncTimerRef.current !== null) {
280
+ clearTimeout(resyncTimerRef.current)
281
+ resyncTimerRef.current = null
282
+ }
283
+ }
284
+ // `resyncSignal` is read as the new baseline, not depended on: reacting to
285
+ // it here would reset the baseline on the very change meant to trigger a
286
+ // resync, and the effect below owns that.
287
+ // eslint-disable-next-line react-hooks/exhaustive-deps
249
288
  }, [dialogId])
250
289
 
251
290
  // Coming back into view after a real absence is treated exactly like a
@@ -256,8 +295,20 @@ export function useJetStreamDialogSubscription({
256
295
  // report that: the tail simply stays quiet, and the conversation on screen
257
296
  // stays frozen at whatever was there when the window went away (the user's
258
297
  // manual fix is a page refresh, which is exactly the history refetch this
259
- // triggers). Bumping the counter rebuilds the consumer from the last sequence
260
- // seen AND tells callers to refetch history.
298
+ // triggers). Rebuilds the consumer from the last sequence seen AND tells
299
+ // callers to refetch history.
300
+ //
301
+ // Every source funnels through here (see `RESYNC_COALESCE_MS`). Restarting
302
+ // the timer rather than dropping is what makes that safe: a request is only
303
+ // ever folded into the one that follows it, never discarded.
304
+ const requestResync = useCallback(() => {
305
+ if (resyncTimerRef.current !== null) clearTimeout(resyncTimerRef.current)
306
+ resyncTimerRef.current = setTimeout(() => {
307
+ resyncTimerRef.current = null
308
+ setReconnectionCount((c) => c + 1)
309
+ }, RESYNC_COALESCE_MS)
310
+ }, [])
311
+
261
312
  useEffect(() => {
262
313
  if (!enabled) return
263
314
  let hiddenSince = document.visibilityState === 'hidden' ? Date.now() : 0
@@ -270,13 +321,27 @@ export function useJetStreamDialogSubscription({
270
321
  const awayMs = hiddenSince === 0 ? 0 : Date.now() - hiddenSince
271
322
  hiddenSince = 0
272
323
  if (awayMs >= RESYNC_AFTER_HIDDEN_MS) {
273
- setReconnectionCount((c) => c + 1)
324
+ requestResync()
274
325
  }
275
326
  }
276
327
 
277
328
  document.addEventListener('visibilitychange', onVisibilityChange)
278
329
  return () => document.removeEventListener('visibilitychange', onVisibilityChange)
279
- }, [enabled])
330
+ }, [enabled, requestResync])
331
+
332
+ useEffect(() => {
333
+ // Held, not consumed, while disabled: it fires once on enable instead.
334
+ // A host reports a signal against the conversation, not against this hook's
335
+ // readiness, so one arriving while the caller is still assembling the
336
+ // subscription describes a gap in the history the caller is fetching right
337
+ // now. Dropping it would leave that fetch's result stale with nothing left
338
+ // to say so.
339
+ if (!enabled) return
340
+ // The mount value is a baseline, not a signal.
341
+ if (resyncSignal <= lastResyncSignalRef.current) return
342
+ lastResyncSignalRef.current = resyncSignal
343
+ requestResync()
344
+ }, [enabled, resyncSignal, requestResync])
280
345
 
281
346
  // Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
282
347
  // we transition into a connected state for a dialog, and whenever the dialog
@@ -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) {
@@ -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 }
@@ -131,6 +131,22 @@ export interface UseJetStreamDialogSubscriptionOptions {
131
131
  /** Consumer inactivity threshold in ms before NATS auto-cleans it. Defaults to the
132
132
  * client's, and applies only to the first consumer — recreations use nats.ws's. */
133
133
  inactiveThresholdMs?: number
134
+ /**
135
+ * A counter the host bumps when it knows, from outside the page, that the tail
136
+ * may have missed something.
137
+ *
138
+ * The hook's own detector is `visibilitychange`, which a browser tab reports
139
+ * honestly and an embedding shell may not — and which says nothing at all
140
+ * about a write made on the page's behalf while it was away. A host holding
141
+ * either kind of knowledge bumps this instead of hoping the page noticed.
142
+ *
143
+ * Treated exactly like a reconnect: the consumer is rebuilt from the highest
144
+ * sequence already seen, and `reconnectionCount` moves so callers refetch
145
+ * persisted history. Any increase counts; the value itself means nothing, and
146
+ * an increase arriving alongside a `visibilitychange` costs one rebuild, not
147
+ * two.
148
+ */
149
+ resyncSignal?: number
134
150
  }
135
151
 
136
152
  export interface UseJetStreamDialogSubscriptionReturn {
@@ -138,10 +154,12 @@ export interface UseJetStreamDialogSubscriptionReturn {
138
154
  isSubscribed: boolean
139
155
  /**
140
156
  * Incremented each time the live tail is re-established: a NATS reconnect, a
141
- * JetStream ordered-consumer recreation, or the page returning to view after
142
- * being hidden long enough to have missed something. All three mean the same
143
- * thing to the caller the tail may have skipped messages, so persisted
144
- * history has to be refetched. Starts at 0.
157
+ * JetStream ordered-consumer recreation, the page returning to view after
158
+ * being hidden long enough to have missed something, or a host reporting the
159
+ * same via `resyncSignal`. All of them mean the same thing to the caller
160
+ * the tail may have skipped messages, so persisted history has to be
161
+ * refetched. One absence counts once even when two sources report it.
162
+ * Starts at 0.
145
163
  */
146
164
  reconnectionCount: number
147
165
  /** Highest JetStream stream sequence observed so far (null before first delivery). */
@@ -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
  /**
@@ -930,3 +930,102 @@ describe('mid-stream refetch (turn partially persisted)', () => {
930
930
  expect(ids(merged)).toEqual([U0.id, A0.id, PERSISTED_PARTIAL.id, unrelatedLive.id])
931
931
  })
932
932
  })
933
+
934
+ describe('ordering of surviving realtime messages', () => {
935
+ // The merge concatenates: all persisted history, then every realtime message
936
+ // the coverage rules kept, each side in its own order. These pin that
937
+ // contract, which nothing asserted before — the suite tested only WHICH
938
+ // messages survive, never where they land — and which an attempt to
939
+ // chronologically interleave the two sides repeatedly broke.
940
+ const H_U0: TestMessage = { ...U0, streamSeq: 5 }
941
+ const H_A0: TestMessage = { ...A0, streamSeq: 10 }
942
+ /**
943
+ * Sent from a notification while the window was away, and persisted. NO
944
+ * `streamSeq`: the backend does not stamp user MESSAGE_REQUEST rows, which is
945
+ * the missing signal that makes interleaving guesswork.
946
+ */
947
+ const AWAY_U: TestMessage = {
948
+ id: 'bbbb0001',
949
+ role: 'user',
950
+ content: 'replied from a notification',
951
+ timestamp: t(3000),
952
+ }
953
+ /** Mingo's answer to it. Assistant rows DO carry a sequence. */
954
+ const AWAY_A: TestMessage = {
955
+ id: 'bbbb0002',
956
+ role: 'assistant',
957
+ content: txt('answered while the window was away'),
958
+ timestamp: t(3100),
959
+ streamSeq: 80,
960
+ }
961
+
962
+ it('appends surviving realtime messages after all persisted history', () => {
963
+ // A technician direct message the snapshot has not caught up with. Its own
964
+ // role's persisted max (H_U0, seq 5) is below it, so coverage keeps it.
965
+ const unpersistedDirect: TestMessage = {
966
+ id: 'direct-1500-old',
967
+ role: 'user',
968
+ content: 'a direct message history has not persisted yet',
969
+ timestamp: t(1500),
970
+ streamSeq: 40,
971
+ }
972
+ const merged = mergeHistoryWithRealtime({
973
+ processedHistory: [H_U0, H_A0, AWAY_U, AWAY_A],
974
+ existingMessages: [H_U0, H_A0, unpersistedDirect],
975
+ streamingMessageId: null,
976
+ historyFetchedAt: 9000,
977
+ historyMaxStreamSeq: 80,
978
+ realtimeSeenStreamSeq: 40,
979
+ })
980
+
981
+ expect(ids(merged)).toEqual([H_U0.id, H_A0.id, AWAY_U.id, AWAY_A.id, unpersistedDirect.id])
982
+ })
983
+
984
+ it('keeps survivors in store order, sequence or no sequence', () => {
985
+ // The guard against reordering the two kinds against each other: an
986
+ // optimistic bubble carries no sequence and a replayed direct message does,
987
+ // and sorting by sequence would render the user's question below the reply
988
+ // that came after it.
989
+ const optimistic: TestMessage = {
990
+ id: 'optimistic-9400-x',
991
+ role: 'user',
992
+ content: 'just typed, no seq of its own',
993
+ timestamp: t(9400),
994
+ }
995
+ const laterDirect: TestMessage = {
996
+ id: 'direct-9500-x',
997
+ role: 'user',
998
+ content: 'and a direct message after it',
999
+ timestamp: t(9500),
1000
+ streamSeq: 40,
1001
+ }
1002
+ const merged = mergeHistoryWithRealtime({
1003
+ processedHistory: [H_U0, H_A0, AWAY_U, AWAY_A],
1004
+ existingMessages: [H_U0, H_A0, optimistic, laterDirect],
1005
+ streamingMessageId: null,
1006
+ // Before either was minted, so neither can be in the snapshot.
1007
+ historyFetchedAt: 9000,
1008
+ historyMaxStreamSeq: 80,
1009
+ realtimeSeenStreamSeq: 40,
1010
+ })
1011
+
1012
+ expect(ids(merged)).toEqual([H_U0.id, H_A0.id, AWAY_U.id, AWAY_A.id, optimistic.id, laterDirect.id])
1013
+ })
1014
+
1015
+ it('keeps history in snapshot order even when its sequences do not climb', () => {
1016
+ // The snapshot is the server's account of the conversation; the merge is
1017
+ // not entitled to second-guess it from a `streamSeq` that looks out of
1018
+ // order, which is what any sort of the two sides together would do.
1019
+ const outOfOrder: TestMessage = { ...H_A0, id: 'aaaa0009', streamSeq: 3 }
1020
+ const merged = mergeHistoryWithRealtime({
1021
+ processedHistory: [H_U0, outOfOrder, AWAY_U, AWAY_A],
1022
+ existingMessages: [H_U0],
1023
+ streamingMessageId: null,
1024
+ historyFetchedAt: 9000,
1025
+ historyMaxStreamSeq: 80,
1026
+ realtimeSeenStreamSeq: 0,
1027
+ })
1028
+
1029
+ expect(ids(merged)).toEqual([H_U0.id, outOfOrder.id, AWAY_U.id, AWAY_A.id])
1030
+ })
1031
+ })