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