@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
|
@@ -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
|
-
*
|
|
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(
|
|
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
|
|
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({
|
|
548
|
+
const accumulator = createMessageSegmentAccumulator({
|
|
549
|
+
onApprove,
|
|
550
|
+
onReject,
|
|
551
|
+
onEscalationApprove,
|
|
552
|
+
onEscalationReject,
|
|
553
|
+
})
|
|
471
554
|
const escalatedApprovals: EscalatedApprovals = new Map()
|
|
555
|
+
const offerResolutions: OfferResolutions = new Map()
|
|
472
556
|
|
|
473
557
|
let currentAssistantId: string | null = null
|
|
474
558
|
let currentAssistantTimestamp: Date | null = null
|
|
@@ -581,8 +665,9 @@ export function processHistoricalMessages(
|
|
|
581
665
|
event,
|
|
582
666
|
accumulator,
|
|
583
667
|
approvalStatuses,
|
|
584
|
-
{ displayApprovalTypes, batchApprovalsEnabled },
|
|
668
|
+
{ displayApprovalTypes, batchApprovalsEnabled, escalationOfferStates },
|
|
585
669
|
escalatedApprovals,
|
|
670
|
+
offerResolutions,
|
|
586
671
|
)
|
|
587
672
|
})
|
|
588
673
|
|
|
@@ -614,12 +699,46 @@ export function processHistoricalMessages(
|
|
|
614
699
|
})
|
|
615
700
|
}
|
|
616
701
|
|
|
702
|
+
applyOfferResolutions(processedMessages, offerResolutions)
|
|
703
|
+
|
|
617
704
|
return {
|
|
618
705
|
messages: processedMessages,
|
|
619
706
|
escalatedApprovals: escalatedApprovals
|
|
620
707
|
}
|
|
621
708
|
}
|
|
622
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Flip escalation-offer cards that were flushed into an EARLIER bubble than
|
|
712
|
+
* their resolution row. Mutates `processedMessages` in place (it is local to
|
|
713
|
+
* the caller and not yet handed out). Uses the same `applyApprovalStatusToSegment`
|
|
714
|
+
* rule as the live projection so the two paths cannot drift.
|
|
715
|
+
*/
|
|
716
|
+
function applyOfferResolutions(
|
|
717
|
+
processedMessages: ProcessedMessage[],
|
|
718
|
+
offerResolutions: OfferResolutions,
|
|
719
|
+
): void {
|
|
720
|
+
if (offerResolutions.size === 0) return
|
|
721
|
+
|
|
722
|
+
processedMessages.forEach((msg, index) => {
|
|
723
|
+
if (!Array.isArray(msg.content)) return
|
|
724
|
+
let changed = false
|
|
725
|
+
const content = msg.content.map((segment) => {
|
|
726
|
+
if (segment.type !== 'escalation_offer') return segment
|
|
727
|
+
const resolution = offerResolutions.get(segment.data.offerId)
|
|
728
|
+
if (!resolution) return segment
|
|
729
|
+
const next = applyApprovalStatusToSegment(
|
|
730
|
+
segment,
|
|
731
|
+
segment.data.offerId,
|
|
732
|
+
resolution.status,
|
|
733
|
+
resolution.resolvedByName,
|
|
734
|
+
)
|
|
735
|
+
if (next !== segment) changed = true
|
|
736
|
+
return next
|
|
737
|
+
})
|
|
738
|
+
if (changed) processedMessages[index] = { ...msg, content }
|
|
739
|
+
})
|
|
740
|
+
}
|
|
741
|
+
|
|
623
742
|
/**
|
|
624
743
|
* Extract error messages from historical messages
|
|
625
744
|
* Returns a separate array of error messages that should be displayed
|
|
@@ -4,7 +4,7 @@ import { useSortable } from '@dnd-kit/sortable'
|
|
|
4
4
|
import { CSS } from '@dnd-kit/utilities'
|
|
5
5
|
import Link from '../../../embed-shims/next-link'
|
|
6
6
|
import * as React from 'react'
|
|
7
|
-
import { LaptopIcon, Flag02Icon, MessagesIcon } from '../../icons-v2-generated'
|
|
7
|
+
import { LaptopIcon, Flag02Icon, MessagesIcon, UserCheckIcon } from '../../icons-v2-generated'
|
|
8
8
|
import { DeletedUserAvatar } from '../../ui/deleted-user-avatar'
|
|
9
9
|
import { SquareAvatar } from '../../ui/square-avatar'
|
|
10
10
|
import { Tag } from '../../ui/tag'
|
|
@@ -119,6 +119,12 @@ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprov
|
|
|
119
119
|
</Tooltip>
|
|
120
120
|
</TooltipProvider>
|
|
121
121
|
)}
|
|
122
|
+
{ticket.escalatedByUser && (
|
|
123
|
+
<div className="flex items-center gap-[var(--spacing-system-xxs)] text-h6 text-ods-open-yellow">
|
|
124
|
+
<UserCheckIcon className="size-4 shrink-0" />
|
|
125
|
+
<span className="truncate">Escalated by User</span>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
122
128
|
{showNewMessage && (
|
|
123
129
|
<Tag
|
|
124
130
|
label="New Message"
|
|
@@ -36,6 +36,13 @@ export interface BoardTicket {
|
|
|
36
36
|
createdAt?: string
|
|
37
37
|
hasNewMessage?: boolean
|
|
38
38
|
pendingApproval?: BoardTicketPendingApproval
|
|
39
|
+
/**
|
|
40
|
+
* The client asked for a human and confirmed the handoff, so this ticket
|
|
41
|
+
* reached its lane by escalation rather than an inactivity auto-escalation or
|
|
42
|
+
* an admin takeover. Called out on the card because it changes how urgently a
|
|
43
|
+
* technician should pick it up.
|
|
44
|
+
*/
|
|
45
|
+
escalatedByUser?: boolean
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
export interface BoardColumnDef {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { useEffect, useState } from "react"
|
|
5
|
-
import {
|
|
5
|
+
import { usePreventScroll } from "@react-aria/overlays"
|
|
6
6
|
import { XmarkIcon } from "../icons-v2-generated"
|
|
7
7
|
import { cn } from "../../utils/cn"
|
|
8
8
|
|
|
@@ -142,19 +142,14 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
142
142
|
return () => clearTimeout(timeout)
|
|
143
143
|
}, [isOpen])
|
|
144
144
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
// the modal added on top — body jumped and the page behind went black
|
|
149
|
-
// while the select was open.
|
|
145
|
+
// Shared ref-counted scroll lock (react-aria) — restores prior styles on
|
|
146
|
+
// release instead of clobbering to 'unset'.
|
|
147
|
+
usePreventScroll({ isDisabled: !isOpen })
|
|
150
148
|
|
|
151
149
|
// Escape key (document-level: top-of-stack semantics for modals)
|
|
152
150
|
useEffect(() => {
|
|
153
151
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
154
|
-
|
|
155
|
-
// Escape it consumes — without this check, closing a select inside
|
|
156
|
-
// the modal closed the WHOLE modal.
|
|
157
|
-
if (event.key === 'Escape' && !event.defaultPrevented) {
|
|
152
|
+
if (event.key === 'Escape') {
|
|
158
153
|
onClose()
|
|
159
154
|
}
|
|
160
155
|
}
|
|
@@ -170,7 +165,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
170
165
|
const state = isOpen ? "open" : "closed"
|
|
171
166
|
|
|
172
167
|
return (
|
|
173
|
-
<RemoveScroll enabled={isOpen} removeScrollBar={false}>
|
|
174
168
|
<div className="fixed inset-0 z-[1300] flex items-end md:items-center justify-center">
|
|
175
169
|
<div
|
|
176
170
|
data-state={state}
|
|
@@ -224,7 +218,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
224
218
|
</ModalContext.Provider>
|
|
225
219
|
</div>
|
|
226
220
|
</div>
|
|
227
|
-
</RemoveScroll>
|
|
228
221
|
)
|
|
229
222
|
}
|
|
230
223
|
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
|
|
4
|
+
import { EscalationOfferMessage } from "../components/chat/escalation-offer-message";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `escalation_offer` message segment — Fae asking the client to confirm a
|
|
8
|
+
* handoff to a human technician (Figma: fae chat › "escalation to admin").
|
|
9
|
+
*
|
|
10
|
+
* Renders THROUGH the client approval card rather than reimplementing it, so
|
|
11
|
+
* it is pixel-identical to `Chat/Client/ApprovalRequestMessage` down to the
|
|
12
|
+
* Approve/Reject labels and the resolved pill. What differs is the wiring:
|
|
13
|
+
* approve/reject call the ticket-escalation GraphQL mutations, never the
|
|
14
|
+
* tool-approval endpoint — which is why it is a distinct segment type.
|
|
15
|
+
*
|
|
16
|
+
* The body copy is backend-fixed; the client never composes it. `origin`
|
|
17
|
+
* (TOOL / MANUAL / a trigger reason) is decoded for telemetry only — every
|
|
18
|
+
* origin renders the same card.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const OFFER_TEXT =
|
|
22
|
+
"Having issues with the AI assistant? This ticket can be handed off to a technician. Fae will no longer respond in this conversation. A technician will review the ticket and reply when available.";
|
|
23
|
+
|
|
24
|
+
const baseData = { offerId: "offer-1", text: OFFER_TEXT, origin: "MANUAL" };
|
|
25
|
+
|
|
26
|
+
const plainDecorator = (Story: React.ComponentType) => (
|
|
27
|
+
<div style={{ maxWidth: 600, background: "var(--color-bg)" }} className="p-4">
|
|
28
|
+
<Story />
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
title: "Chat/Client/EscalationOfferMessage",
|
|
34
|
+
component: EscalationOfferMessage,
|
|
35
|
+
tags: ["autodocs"],
|
|
36
|
+
parameters: {
|
|
37
|
+
docs: {
|
|
38
|
+
description: {
|
|
39
|
+
component:
|
|
40
|
+
"Renders an `escalation_offer` segment: the backend-fixed handoff prompt with Approve/Reject, or a resolved status pill. Approve performs the handoff (ticket → Tech Required, Fae goes silent); Reject just records the choice.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
decorators: [plainDecorator],
|
|
45
|
+
} satisfies Meta<typeof EscalationOfferMessage>;
|
|
46
|
+
|
|
47
|
+
export default meta;
|
|
48
|
+
type Story = StoryObj<typeof meta>;
|
|
49
|
+
|
|
50
|
+
/** Pending — the client has not decided yet. */
|
|
51
|
+
export const Pending: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
data: baseData,
|
|
54
|
+
status: "pending",
|
|
55
|
+
onApprove: () => {},
|
|
56
|
+
onReject: () => {},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Approved — the handoff ran. Paired in the thread with `TicketEscalatedMessage`. */
|
|
61
|
+
export const ApprovedByUser: Story = {
|
|
62
|
+
args: { data: baseData, status: "approved", resolvedByName: "John Smith" },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Declined — the client chose to keep working with Fae. */
|
|
66
|
+
export const DeclinedByUser: Story = {
|
|
67
|
+
args: { data: baseData, status: "rejected", resolvedByName: "John Smith" },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Superseded — the client typed a new message over the pending offer, which
|
|
72
|
+
* the backend treats as declining it. Maps onto the shared `cancelled` status.
|
|
73
|
+
*/
|
|
74
|
+
export const Superseded: Story = {
|
|
75
|
+
args: { data: baseData, status: "cancelled", resolvedByName: "John Smith" },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Raised by Fae's own `escalateToHuman` tool call rather than the header action. */
|
|
79
|
+
export const FromToolCall: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
data: { ...baseData, origin: "TOOL" },
|
|
82
|
+
status: "pending",
|
|
83
|
+
onApprove: () => {},
|
|
84
|
+
onReject: () => {},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -150,6 +150,18 @@ export const WithNewMessage: Story = {
|
|
|
150
150
|
},
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The client asked for a human and confirmed the handoff — an amber footer row
|
|
155
|
+
* distinguishing it from an inactivity auto-escalation or an admin takeover,
|
|
156
|
+
* which reach the same lane silently. Driven by the ticket's `escalatedByUser`
|
|
157
|
+
* field, not by a label.
|
|
158
|
+
*/
|
|
159
|
+
export const EscalatedByUser: Story = {
|
|
160
|
+
args: {
|
|
161
|
+
ticket: { ...BASE_TICKET, id: 'ticket-escalated', escalatedByUser: true },
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
153
165
|
/**
|
|
154
166
|
* Pending CLIENT approval — collapsed grey "Pending client approval" row with a
|
|
155
167
|
* loader glyph. Expand it to reach the shared approve/reject affordance.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
|
|
4
|
+
import { TicketEscalatedMessage } from "../components/chat/ticket-escalated-message";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `ticket_escalated` message segment — the handoff receipt (Figma:
|
|
8
|
+
* `ai-assistant-info`, type=escalation).
|
|
9
|
+
*
|
|
10
|
+
* A first-class block on the wire, NOT something the client infers from an
|
|
11
|
+
* escalation offer's state, so it also covers the inactivity auto-escalation —
|
|
12
|
+
* a path that raises no offer at all.
|
|
13
|
+
*
|
|
14
|
+
* The body is whatever the backend authored, so the reason it gives stays
|
|
15
|
+
* server-side: the card never branches on `data.reason`, which is why a reason
|
|
16
|
+
* added server-side needs no client change. The body WRAPS rather than
|
|
17
|
+
* truncating as the mock does — see the component for why — so the block is
|
|
18
|
+
* 72px only while the text fits two lines.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const baseData = {
|
|
22
|
+
ticketId: "68a1f0c2b3d4e5f6a7b8c9d0",
|
|
23
|
+
ticketNumber: 1002,
|
|
24
|
+
reason: "INACTIVITY",
|
|
25
|
+
text: "Automatically escalated to a human technician because the conversation had no new messages for an extended period.",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Fixed so the timestamp doesn't churn between snapshots. */
|
|
29
|
+
const TIMESTAMP = new Date("2026-08-07T14:47:00");
|
|
30
|
+
|
|
31
|
+
const plainDecorator = (Story: React.ComponentType) => (
|
|
32
|
+
<div style={{ maxWidth: 600, background: "var(--color-bg)" }} className="p-4">
|
|
33
|
+
<Story />
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const meta = {
|
|
38
|
+
title: "Chat/Client/TicketEscalatedMessage",
|
|
39
|
+
component: TicketEscalatedMessage,
|
|
40
|
+
tags: ["autodocs"],
|
|
41
|
+
parameters: {
|
|
42
|
+
docs: {
|
|
43
|
+
description: {
|
|
44
|
+
component:
|
|
45
|
+
"Renders a `TICKET_ESCALATED` block: a bordered info tile, the fixed 'Handed Off to a Technician' title, the message timestamp, and the backend's explanation.",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
decorators: [plainDecorator],
|
|
50
|
+
} satisfies Meta<typeof TicketEscalatedMessage>;
|
|
51
|
+
|
|
52
|
+
export default meta;
|
|
53
|
+
type Story = StoryObj<typeof meta>;
|
|
54
|
+
|
|
55
|
+
/** The inactivity auto-escalation — the only reason the wire defines today. */
|
|
56
|
+
export const Inactivity: Story = {
|
|
57
|
+
args: { data: baseData, timestamp: TIMESTAMP },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `text` is nullable on the wire; the card falls back to the design's generic
|
|
62
|
+
* line rather than rendering an empty body.
|
|
63
|
+
*/
|
|
64
|
+
export const NoBodyFallback: Story = {
|
|
65
|
+
args: { data: { ...baseData, text: undefined }, timestamp: TIMESTAMP },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** No timestamp — the row collapses to title-only, body unchanged. */
|
|
69
|
+
export const WithoutTimestamp: Story = {
|
|
70
|
+
args: { data: baseData },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Long explanation — wraps and the block grows; nothing is clipped. */
|
|
74
|
+
export const LongBody: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
data: {
|
|
77
|
+
...baseData,
|
|
78
|
+
text: "Automatically escalated to a human technician because the conversation had no new messages for an extended period while the ticket was still assigned to the AI assistant, and no diagnostic step produced a resolution.",
|
|
79
|
+
},
|
|
80
|
+
timestamp: TIMESTAMP,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A reason the client has never seen. The open `TicketEscalationReason` union
|
|
86
|
+
* plus rendering from `text` mean a reason added server-side needs no client
|
|
87
|
+
* change — this story is the regression guard for that.
|
|
88
|
+
*/
|
|
89
|
+
export const UnknownReason: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
data: {
|
|
92
|
+
...baseData,
|
|
93
|
+
reason: "USER_REQUESTED",
|
|
94
|
+
text: "Escalated at the user's request.",
|
|
95
|
+
},
|
|
96
|
+
timestamp: TIMESTAMP,
|
|
97
|
+
},
|
|
98
|
+
};
|