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