@flamingo-stack/openframe-frontend-core 0.0.537 → 0.0.538-1791.4733.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 +15 -1
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +15 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +15 -0
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-7FXZHI5D.js → chunk-4BK5Z763.js} +2 -2
- package/dist/{chunk-C4LEH3VE.js → chunk-56QT3JBZ.js} +2 -2
- package/dist/{chunk-LFMIAPBP.js → chunk-67CKAX7Z.js} +2 -2
- package/dist/{chunk-CZHCXCWL.js → chunk-73W4L656.js} +2 -2
- package/dist/{chunk-TCSSYTDN.js → chunk-7CBQWZAH.js} +4 -4
- package/dist/{chunk-YIDGB5KP.js → chunk-BNF6FZLP.js} +4 -4
- package/dist/{chunk-CATY4DVR.cjs → chunk-BUKQ6B65.cjs} +87 -87
- package/dist/{chunk-CATY4DVR.cjs.map → chunk-BUKQ6B65.cjs.map} +1 -1
- package/dist/{chunk-KKZ2QMQD.cjs → chunk-C6776X43.cjs} +26 -26
- package/dist/{chunk-KKZ2QMQD.cjs.map → chunk-C6776X43.cjs.map} +1 -1
- package/dist/{chunk-MOIHWYSA.js → chunk-C6NKOHFW.js} +2 -2
- package/dist/{chunk-HRC56BQW.js → chunk-EH6LNTOT.js} +5 -5
- package/dist/{chunk-YPI3JIR6.cjs → chunk-F4QOOHZL.cjs} +11 -11
- package/dist/{chunk-YPI3JIR6.cjs.map → chunk-F4QOOHZL.cjs.map} +1 -1
- package/dist/{chunk-3GXMS4OP.js → chunk-KUKHUECE.js} +2 -2
- package/dist/{chunk-DHSVBGOX.cjs → chunk-LJW7B6GY.cjs} +9 -9
- package/dist/{chunk-DHSVBGOX.cjs.map → chunk-LJW7B6GY.cjs.map} +1 -1
- package/dist/{chunk-WJIBB3KT.cjs → chunk-MGYNZ7SA.cjs} +31 -31
- package/dist/{chunk-WJIBB3KT.cjs.map → chunk-MGYNZ7SA.cjs.map} +1 -1
- package/dist/{chunk-OYTM3YXO.cjs → chunk-NE67LT3B.cjs} +7 -7
- package/dist/{chunk-OYTM3YXO.cjs.map → chunk-NE67LT3B.cjs.map} +1 -1
- package/dist/{chunk-SKIM263K.cjs → chunk-NUHDE7CB.cjs} +37 -37
- package/dist/{chunk-SKIM263K.cjs.map → chunk-NUHDE7CB.cjs.map} +1 -1
- package/dist/{chunk-MDP3YKFI.cjs → chunk-PFGRHRBO.cjs} +5 -5
- package/dist/{chunk-MDP3YKFI.cjs.map → chunk-PFGRHRBO.cjs.map} +1 -1
- package/dist/{chunk-SGNWSUL3.js → chunk-QK4SQAYG.js} +6 -6
- package/dist/{chunk-YMPRMZDD.cjs → chunk-QQ3HUSDJ.cjs} +599 -328
- package/dist/chunk-QQ3HUSDJ.cjs.map +1 -0
- package/dist/{chunk-QPPG3KHW.cjs → chunk-QU6P2U2Q.cjs} +3 -3
- package/dist/{chunk-QPPG3KHW.cjs.map → chunk-QU6P2U2Q.cjs.map} +1 -1
- package/dist/{chunk-V4D5MPB7.cjs → chunk-R3XO2KO4.cjs} +4 -4
- package/dist/{chunk-V4D5MPB7.cjs.map → chunk-R3XO2KO4.cjs.map} +1 -1
- package/dist/{chunk-7EHGP4QK.js → chunk-RBBWKSRC.js} +4638 -4367
- package/dist/chunk-RBBWKSRC.js.map +1 -0
- package/dist/{chunk-VSIHO7DJ.cjs → chunk-TWVRCPWY.cjs} +61 -61
- package/dist/{chunk-VSIHO7DJ.cjs.map → chunk-TWVRCPWY.cjs.map} +1 -1
- package/dist/{chunk-CQQXB25K.js → chunk-UBEVU6LS.js} +2 -2
- package/dist/{chunk-7WNJ7RFO.cjs → chunk-YO5BWF4L.cjs} +14 -14
- package/dist/{chunk-7WNJ7RFO.cjs.map → chunk-YO5BWF4L.cjs.map} +1 -1
- package/dist/{chunk-WHUEUACV.js → chunk-ZBRKTF6S.js} +2 -2
- package/dist/{chunk-5JPSQOZQ.js → chunk-ZHW5VLHY.js} +2 -2
- package/dist/{chunk-YEKXTTAC.cjs → chunk-ZNWW4WAT.cjs} +26 -26
- package/dist/{chunk-YEKXTTAC.cjs.map → chunk-ZNWW4WAT.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/ai-assistant-info.d.ts +31 -0
- package/dist/components/chat/ai-assistant-info.d.ts.map +1 -0
- package/dist/components/chat/chat-message-enhanced.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.map +1 -1
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
- package/dist/components/chat/ticket-escalated-message.d.ts +2 -8
- package/dist/components/chat/ticket-escalated-message.d.ts.map +1 -1
- package/dist/components/chat/ticket-event-message.d.ts +24 -0
- package/dist/components/chat/ticket-event-message.d.ts.map +1 -0
- package/dist/components/chat/types/message.types.d.ts +51 -2
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +13 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +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/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 +144 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -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 +14 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +13 -1
- package/dist/components/ui/reopen-ticket-modal.d.ts +42 -0
- package/dist/components/ui/reopen-ticket-modal.d.ts.map +1 -0
- package/dist/components/ui/take-over-ticket-modal.d.ts +5 -0
- package/dist/components/ui/take-over-ticket-modal.d.ts.map +1 -1
- package/dist/index.cjs +14 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -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 +44 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +32 -0
- package/src/chat-protocol/events.ts +16 -0
- package/src/chat-protocol/nats-decoder.ts +27 -0
- package/src/components/chat/__tests__/ticket-event.test.ts +194 -0
- package/src/components/chat/ai-assistant-info.tsx +71 -0
- package/src/components/chat/chat-message-enhanced.tsx +3 -0
- package/src/components/chat/index.ts +2 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +19 -0
- package/src/components/chat/stream/message-mutations.ts +5 -0
- package/src/components/chat/ticket-escalated-message.tsx +10 -32
- package/src/components/chat/ticket-event-message.tsx +79 -0
- package/src/components/chat/types/message.types.ts +60 -1
- package/src/components/chat/utils/message-segment-accumulator.ts +41 -0
- package/src/components/chat/utils/process-historical-messages.ts +31 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/reopen-ticket-modal.tsx +174 -0
- package/src/components/ui/take-over-ticket-modal.tsx +7 -4
- package/src/stories/ReopenTicketModal.stories.tsx +114 -0
- package/src/stories/TicketEventMessage.stories.tsx +100 -0
- package/dist/chunk-7EHGP4QK.js.map +0 -1
- package/dist/chunk-YMPRMZDD.cjs.map +0 -1
- /package/dist/{chunk-7FXZHI5D.js.map → chunk-4BK5Z763.js.map} +0 -0
- /package/dist/{chunk-C4LEH3VE.js.map → chunk-56QT3JBZ.js.map} +0 -0
- /package/dist/{chunk-LFMIAPBP.js.map → chunk-67CKAX7Z.js.map} +0 -0
- /package/dist/{chunk-CZHCXCWL.js.map → chunk-73W4L656.js.map} +0 -0
- /package/dist/{chunk-TCSSYTDN.js.map → chunk-7CBQWZAH.js.map} +0 -0
- /package/dist/{chunk-YIDGB5KP.js.map → chunk-BNF6FZLP.js.map} +0 -0
- /package/dist/{chunk-MOIHWYSA.js.map → chunk-C6NKOHFW.js.map} +0 -0
- /package/dist/{chunk-HRC56BQW.js.map → chunk-EH6LNTOT.js.map} +0 -0
- /package/dist/{chunk-3GXMS4OP.js.map → chunk-KUKHUECE.js.map} +0 -0
- /package/dist/{chunk-SGNWSUL3.js.map → chunk-QK4SQAYG.js.map} +0 -0
- /package/dist/{chunk-CQQXB25K.js.map → chunk-UBEVU6LS.js.map} +0 -0
- /package/dist/{chunk-WHUEUACV.js.map → chunk-ZBRKTF6S.js.map} +0 -0
- /package/dist/{chunk-5JPSQOZQ.js.map → chunk-ZHW5VLHY.js.map} +0 -0
package/package.json
CHANGED
|
@@ -406,6 +406,50 @@ exports[`decodeNatsChunk — golden corpus > maps the full recorded corpus to no
|
|
|
406
406
|
},
|
|
407
407
|
"ticket_escalated_missing_reason": null,
|
|
408
408
|
"ticket_escalated_missing_ticket_id": null,
|
|
409
|
+
"ticket_event_blank_reason_dropped": {
|
|
410
|
+
"actorId": undefined,
|
|
411
|
+
"actorName": undefined,
|
|
412
|
+
"actorType": undefined,
|
|
413
|
+
"kind": "RESOLVED",
|
|
414
|
+
"reason": undefined,
|
|
415
|
+
"type": "ticket-event",
|
|
416
|
+
},
|
|
417
|
+
"ticket_event_missing_kind": null,
|
|
418
|
+
"ticket_event_reopened_with_reason": {
|
|
419
|
+
"actorId": "user-42",
|
|
420
|
+
"actorName": "John Smith",
|
|
421
|
+
"actorType": "CLIENT",
|
|
422
|
+
"kind": "REOPENED",
|
|
423
|
+
"reason": "The printer stopped working again",
|
|
424
|
+
"seq": 413,
|
|
425
|
+
"type": "ticket-event",
|
|
426
|
+
},
|
|
427
|
+
"ticket_event_resolved": {
|
|
428
|
+
"actorId": "fae",
|
|
429
|
+
"actorName": "Fae",
|
|
430
|
+
"actorType": "AI",
|
|
431
|
+
"kind": "RESOLVED",
|
|
432
|
+
"reason": undefined,
|
|
433
|
+
"seq": 412,
|
|
434
|
+
"type": "ticket-event",
|
|
435
|
+
},
|
|
436
|
+
"ticket_event_stream_seq_wins": {
|
|
437
|
+
"actorId": undefined,
|
|
438
|
+
"actorName": undefined,
|
|
439
|
+
"actorType": undefined,
|
|
440
|
+
"kind": "RESOLVED",
|
|
441
|
+
"reason": undefined,
|
|
442
|
+
"seq": 500,
|
|
443
|
+
"type": "ticket-event",
|
|
444
|
+
},
|
|
445
|
+
"ticket_event_unknown_kind": {
|
|
446
|
+
"actorId": undefined,
|
|
447
|
+
"actorName": "Roman Smith",
|
|
448
|
+
"actorType": undefined,
|
|
449
|
+
"kind": "ON_HOLD",
|
|
450
|
+
"reason": undefined,
|
|
451
|
+
"type": "ticket-event",
|
|
452
|
+
},
|
|
409
453
|
"token_usage": {
|
|
410
454
|
"contextSize": 180000,
|
|
411
455
|
"inputTokensSize": 1200,
|
|
@@ -257,6 +257,38 @@ const CORPUS: Record<string, unknown> = {
|
|
|
257
257
|
ticket_escalated_missing_reason: { type: 'TICKET_ESCALATED', ticketId: 'ticket-1' },
|
|
258
258
|
ticket_escalated_missing_ticket_id: { type: 'TICKET_ESCALATED', reason: 'INACTIVITY' },
|
|
259
259
|
|
|
260
|
+
// TICKET_EVENT — standalone lifecycle receipt. `kind` is an OPEN string:
|
|
261
|
+
// unknown kinds must decode (neutral render), only a missing/blank kind is
|
|
262
|
+
// malformed. The chunk names its JetStream sequence `sequenceId` in the
|
|
263
|
+
// payload; it backfills `seq` when the transport didn't stamp `streamSeq`.
|
|
264
|
+
ticket_event_resolved: {
|
|
265
|
+
type: 'TICKET_EVENT',
|
|
266
|
+
kind: 'RESOLVED',
|
|
267
|
+
actorId: 'fae',
|
|
268
|
+
actorName: 'Fae',
|
|
269
|
+
actorType: 'AI',
|
|
270
|
+
sequenceId: 412,
|
|
271
|
+
},
|
|
272
|
+
ticket_event_reopened_with_reason: {
|
|
273
|
+
type: 'TICKET_EVENT',
|
|
274
|
+
kind: 'REOPENED',
|
|
275
|
+
actorId: 'user-42',
|
|
276
|
+
actorName: 'John Smith',
|
|
277
|
+
actorType: 'CLIENT',
|
|
278
|
+
reason: 'The printer stopped working again',
|
|
279
|
+
sequenceId: 413,
|
|
280
|
+
},
|
|
281
|
+
ticket_event_unknown_kind: { type: 'TICKET_EVENT', kind: 'ON_HOLD', actorName: 'Roman Smith' },
|
|
282
|
+
ticket_event_missing_kind: { type: 'TICKET_EVENT', actorName: 'Fae' },
|
|
283
|
+
ticket_event_blank_reason_dropped: { type: 'TICKET_EVENT', kind: 'RESOLVED', reason: ' ' },
|
|
284
|
+
// Transport-stamped streamSeq wins over the payload's sequenceId copy.
|
|
285
|
+
ticket_event_stream_seq_wins: {
|
|
286
|
+
type: 'TICKET_EVENT',
|
|
287
|
+
kind: 'RESOLVED',
|
|
288
|
+
streamSeq: 500,
|
|
289
|
+
sequenceId: 499,
|
|
290
|
+
},
|
|
291
|
+
|
|
260
292
|
approval_result_approved: {
|
|
261
293
|
type: 'APPROVAL_RESULT',
|
|
262
294
|
approvalRequestId: 'req-1',
|
|
@@ -251,6 +251,21 @@ export interface TicketEscalatedEvent extends ChatStreamEventBase {
|
|
|
251
251
|
text?: string
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/** Ticket lifecycle receipt (`TICKET_EVENT`) — the ticket was resolved,
|
|
255
|
+
* reopened, etc. `kind` is an OPEN vocabulary (RESOLVED/REOPENED today):
|
|
256
|
+
* an unknown kind still decodes and renders as a neutral line rather than
|
|
257
|
+
* being dropped, so the backend can add kinds without a client release.
|
|
258
|
+
* Arrives standalone (outside MESSAGE_START/END), like `ticket-escalated`. */
|
|
259
|
+
export interface TicketEventEvent extends ChatStreamEventBase {
|
|
260
|
+
type: 'ticket-event'
|
|
261
|
+
kind: string
|
|
262
|
+
actorId?: string
|
|
263
|
+
actorName?: string
|
|
264
|
+
/** Who acted — e.g. an AI agent vs a human technician. Open string. */
|
|
265
|
+
actorType?: string
|
|
266
|
+
reason?: string
|
|
267
|
+
}
|
|
268
|
+
|
|
254
269
|
/** Per-turn metadata. Raw wire values pass through UNVALIDATED — the
|
|
255
270
|
* consumer replicates the legacy truthiness/typeof gates (so a
|
|
256
271
|
* malformed frame degrades identically to the pre-SSOT parser). */
|
|
@@ -365,6 +380,7 @@ export type ChatStreamEvent =
|
|
|
365
380
|
| EscalationOfferEvent
|
|
366
381
|
| EscalationOfferResolvedEvent
|
|
367
382
|
| TicketEscalatedEvent
|
|
383
|
+
| TicketEventEvent
|
|
368
384
|
| ChatMetadataEvent
|
|
369
385
|
| UsageEvent
|
|
370
386
|
| TokenUsageEvent
|
|
@@ -370,6 +370,33 @@ export function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
case MESSAGE_TYPE.TICKET_EVENT: {
|
|
374
|
+
// `kind` is the only required field, and deliberately an OPEN string:
|
|
375
|
+
// unknown kinds must render (as a neutral line), not be dropped.
|
|
376
|
+
const kind = typeof data.kind === 'string' ? data.kind.trim() : ''
|
|
377
|
+
if (!kind) return null
|
|
378
|
+
// This chunk names its own JetStream sequence `sequenceId` in the
|
|
379
|
+
// payload (the persisted row's `lastChunkStreamSeq` equals it — the
|
|
380
|
+
// dedupe key). Prefer the transport-stamped `streamSeq` envelope like
|
|
381
|
+
// every other chunk; fall back to the payload copy when absent.
|
|
382
|
+
const eventSeq: { seq?: number } =
|
|
383
|
+
seq.seq !== undefined
|
|
384
|
+
? seq
|
|
385
|
+
: typeof data.sequenceId === 'number'
|
|
386
|
+
? { seq: data.sequenceId }
|
|
387
|
+
: {}
|
|
388
|
+
return {
|
|
389
|
+
type: 'ticket-event',
|
|
390
|
+
kind,
|
|
391
|
+
actorId: typeof data.actorId === 'string' ? data.actorId : undefined,
|
|
392
|
+
actorName: typeof data.actorName === 'string' ? data.actorName : undefined,
|
|
393
|
+
actorType: typeof data.actorType === 'string' ? data.actorType : undefined,
|
|
394
|
+
reason:
|
|
395
|
+
typeof data.reason === 'string' && data.reason.trim() ? data.reason : undefined,
|
|
396
|
+
...eventSeq,
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
373
400
|
case MESSAGE_TYPE.ERROR:
|
|
374
401
|
return {
|
|
375
402
|
type: 'error',
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TICKET_EVENT — the ticket lifecycle receipt (resolved / reopened / open
|
|
3
|
+
* vocabulary), across both the live NATS path and history replay.
|
|
4
|
+
*
|
|
5
|
+
* The behaviours worth pinning:
|
|
6
|
+
* - the chunk arrives STANDALONE (no MESSAGE_START at all — resolution can
|
|
7
|
+
* fire from a PSA-side action with no turn open), so it must mint/append
|
|
8
|
+
* to the trailing bubble like `ticket-escalated` does;
|
|
9
|
+
* - dedupe identity is the stream sequence, with payload-equality as the
|
|
10
|
+
* fallback for the hydrate-then-catch-up overlap where the hydrated twin
|
|
11
|
+
* is seq-less (the persisted row's seq lives on the message, not in
|
|
12
|
+
* `messageData`);
|
|
13
|
+
* - `kind` is OPEN: an unknown kind decodes and renders, only a missing
|
|
14
|
+
* kind is malformed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder'
|
|
19
|
+
import { createChatStreamReducer } from '../stream/chat-stream-reducer'
|
|
20
|
+
import { MessageSegmentAccumulator } from '../utils/message-segment-accumulator'
|
|
21
|
+
import { processHistoricalMessages } from '../utils/process-historical-messages'
|
|
22
|
+
import type { HistoricalMessage, MessageSegment, TicketEventSegment } from '../types'
|
|
23
|
+
|
|
24
|
+
const resolvedChunk = (streamSeq: number, over: Record<string, unknown> = {}) => ({
|
|
25
|
+
type: 'TICKET_EVENT',
|
|
26
|
+
kind: 'RESOLVED',
|
|
27
|
+
actorId: 'fae',
|
|
28
|
+
actorName: 'Fae',
|
|
29
|
+
actorType: 'AI',
|
|
30
|
+
streamSeq,
|
|
31
|
+
...over,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const events = (segments: MessageSegment[] | string | undefined): TicketEventSegment[] =>
|
|
35
|
+
Array.isArray(segments)
|
|
36
|
+
? (segments.filter((s) => s.type === 'ticket_event') as TicketEventSegment[])
|
|
37
|
+
: []
|
|
38
|
+
|
|
39
|
+
describe('decodeNatsChunk — TICKET_EVENT', () => {
|
|
40
|
+
it('decodes the resolution receipt with its actor fields', () => {
|
|
41
|
+
expect(decodeNatsChunk(resolvedChunk(7))).toEqual({
|
|
42
|
+
type: 'ticket-event',
|
|
43
|
+
kind: 'RESOLVED',
|
|
44
|
+
actorId: 'fae',
|
|
45
|
+
actorName: 'Fae',
|
|
46
|
+
actorType: 'AI',
|
|
47
|
+
reason: undefined,
|
|
48
|
+
seq: 7,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('falls back to the payload `sequenceId` when the transport did not stamp `streamSeq`', () => {
|
|
53
|
+
const event = decodeNatsChunk({ type: 'TICKET_EVENT', kind: 'REOPENED', sequenceId: 42 })
|
|
54
|
+
expect(event).toMatchObject({ type: 'ticket-event', seq: 42 })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('prefers the transport-stamped `streamSeq` over the payload copy', () => {
|
|
58
|
+
expect(decodeNatsChunk(resolvedChunk(500, { sequenceId: 499 }))).toMatchObject({ seq: 500 })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('keeps unknown kinds — the vocabulary is open', () => {
|
|
62
|
+
expect(decodeNatsChunk({ type: 'TICKET_EVENT', kind: 'ON_HOLD', streamSeq: 1 })).toMatchObject({
|
|
63
|
+
type: 'ticket-event',
|
|
64
|
+
kind: 'ON_HOLD',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('drops only a chunk with no kind at all', () => {
|
|
69
|
+
expect(decodeNatsChunk({ type: 'TICKET_EVENT', streamSeq: 1 })).toBeNull()
|
|
70
|
+
expect(decodeNatsChunk({ type: 'TICKET_EVENT', kind: ' ', streamSeq: 1 })).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('chat stream reducer — ticket event', () => {
|
|
75
|
+
const trailingSegments = (reducer: ReturnType<typeof createChatStreamReducer>): MessageSegment[] => {
|
|
76
|
+
const { messages } = reducer.state
|
|
77
|
+
return messages[messages.length - 1]?.segments ?? []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const feed = (reducer: ReturnType<typeof createChatStreamReducer>, chunks: unknown[]) => {
|
|
81
|
+
for (const chunk of chunks) {
|
|
82
|
+
const event = decodeNatsChunk(chunk)
|
|
83
|
+
if (event) reducer.apply(event)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it('renders a receipt arriving with no turn open at all', () => {
|
|
88
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
89
|
+
feed(reducer, [resolvedChunk(1)])
|
|
90
|
+
expect(events(trailingSegments(reducer))).toHaveLength(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('appends to the trailing bubble without wiping the reply the user is reading', () => {
|
|
94
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
95
|
+
feed(reducer, [
|
|
96
|
+
{ type: 'MESSAGE_START', streamSeq: 1 },
|
|
97
|
+
{ type: 'TEXT', text: 'Excellent! Feel free to reach out.', streamSeq: 2 },
|
|
98
|
+
{ type: 'MESSAGE_END', streamSeq: 3 },
|
|
99
|
+
resolvedChunk(4),
|
|
100
|
+
])
|
|
101
|
+
const segments = trailingSegments(reducer)
|
|
102
|
+
expect(segments.find((s: MessageSegment) => s.type === 'text')).toMatchObject({
|
|
103
|
+
text: 'Excellent! Feel free to reach out.',
|
|
104
|
+
})
|
|
105
|
+
expect(events(segments)).toHaveLength(1)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('is idempotent across a redelivered chunk (JetStream catch-up)', () => {
|
|
109
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
110
|
+
feed(reducer, [resolvedChunk(2), { type: 'MESSAGE_END', streamSeq: 3 }])
|
|
111
|
+
// A replay re-decodes the same chunk; the seq gate drops it, but the
|
|
112
|
+
// upsert must hold even if the gate ever moves.
|
|
113
|
+
const event = decodeNatsChunk(resolvedChunk(2))
|
|
114
|
+
if (event) reducer.apply(event)
|
|
115
|
+
expect(events(trailingSegments(reducer))).toHaveLength(1)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('keeps distinct lifecycle events distinct (resolve → reopen → resolve)', () => {
|
|
119
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
120
|
+
feed(reducer, [
|
|
121
|
+
resolvedChunk(1),
|
|
122
|
+
{ type: 'TICKET_EVENT', kind: 'REOPENED', reason: 'Still broken', streamSeq: 2 },
|
|
123
|
+
resolvedChunk(3, { actorName: 'Roman Smith', actorType: 'TECHNICIAN', actorId: 'u-1' }),
|
|
124
|
+
])
|
|
125
|
+
expect(events(trailingSegments(reducer))).toHaveLength(3)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('accumulator — hydrate/catch-up overlap', () => {
|
|
130
|
+
it('matches a seq-less hydrated twin by payload equality instead of stacking', () => {
|
|
131
|
+
const accumulator = new MessageSegmentAccumulator()
|
|
132
|
+
// Hydrated from history: the persisted row's seq lives on the message,
|
|
133
|
+
// so the restored segment carries none.
|
|
134
|
+
accumulator.addTicketEvent({ kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' })
|
|
135
|
+
// The same event redelivered live, now with its stream sequence.
|
|
136
|
+
const segments = accumulator.addTicketEvent(
|
|
137
|
+
{ kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' },
|
|
138
|
+
412,
|
|
139
|
+
)
|
|
140
|
+
const rendered = events(segments)
|
|
141
|
+
expect(rendered).toHaveLength(1)
|
|
142
|
+
expect(rendered[0].streamSeq).toBe(412)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('does NOT collapse two different events of the same kind', () => {
|
|
146
|
+
const accumulator = new MessageSegmentAccumulator()
|
|
147
|
+
accumulator.addTicketEvent({ kind: 'RESOLVED', actorName: 'Fae', actorType: 'AI' }, 1)
|
|
148
|
+
const segments = accumulator.addTicketEvent(
|
|
149
|
+
{ kind: 'RESOLVED', actorName: 'Roman Smith', actorType: 'TECHNICIAN' },
|
|
150
|
+
9,
|
|
151
|
+
)
|
|
152
|
+
expect(events(segments)).toHaveLength(2)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('processHistoricalMessages — ticket event', () => {
|
|
157
|
+
const historyRow = (id: string, data: Record<string, unknown>): HistoricalMessage => ({
|
|
158
|
+
id,
|
|
159
|
+
createdAt: '2026-08-06T12:00:00Z',
|
|
160
|
+
owner: { type: 'ASSISTANT' },
|
|
161
|
+
messageData: [data as never],
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('replays the persisted row through the same mapper as the live path', () => {
|
|
165
|
+
const { messages } = processHistoricalMessages([
|
|
166
|
+
historyRow('m1', {
|
|
167
|
+
type: 'TICKET_EVENT',
|
|
168
|
+
kind: 'REOPENED',
|
|
169
|
+
actorId: 'user-42',
|
|
170
|
+
actorName: 'John Smith',
|
|
171
|
+
actorType: 'CLIENT',
|
|
172
|
+
reason: 'The printer stopped working again',
|
|
173
|
+
}),
|
|
174
|
+
])
|
|
175
|
+
expect(messages).toHaveLength(1)
|
|
176
|
+
expect(events(messages[0].content)[0]).toMatchObject({
|
|
177
|
+
data: { kind: 'REOPENED', reason: 'The printer stopped working again' },
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('keeps an unknown persisted kind renderable', () => {
|
|
182
|
+
const { messages } = processHistoricalMessages([
|
|
183
|
+
historyRow('m1', { type: 'TICKET_EVENT', kind: 'ON_HOLD' }),
|
|
184
|
+
])
|
|
185
|
+
expect(events(messages[0]?.content ?? [])).toHaveLength(1)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('skips a malformed row with no kind', () => {
|
|
189
|
+
const { messages } = processHistoricalMessages([
|
|
190
|
+
historyRow('m1', { type: 'TICKET_EVENT', actorName: 'Fae' }),
|
|
191
|
+
])
|
|
192
|
+
expect(messages.flatMap((m) => events(m.content))).toHaveLength(0)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
import { formatTime } from "../../utils/format-date"
|
|
6
|
+
|
|
7
|
+
export interface AiAssistantInfoProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
/** Rendered inside the standard 48px framed box (typically a 24px icon). */
|
|
9
|
+
icon?: ReactNode
|
|
10
|
+
/** Replaces the framed icon box entirely (e.g. a round 48px avatar for the
|
|
11
|
+
* `direct-chat` variant). Wins over `icon` when both are set. */
|
|
12
|
+
leading?: ReactNode
|
|
13
|
+
title: string
|
|
14
|
+
/** Secondary line under the title. Omitted → the row is title-only. */
|
|
15
|
+
body?: ReactNode
|
|
16
|
+
/** Timestamp of the message row this block came in on. */
|
|
17
|
+
timestamp?: Date
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Figma `ai-assistant-info` — the shared in-thread status card: a framed
|
|
22
|
+
* icon (or avatar), a title with a right-aligned time, and a secondary line.
|
|
23
|
+
* One presentational shell for every lifecycle receipt in a chat thread
|
|
24
|
+
* (handoff, technician joined, ticket resolved/reopened, …); the per-event
|
|
25
|
+
* components (`TicketEscalatedMessage`, `TicketEventMessage`) own the copy
|
|
26
|
+
* and icon choice and delegate the chrome here.
|
|
27
|
+
*
|
|
28
|
+
* The body WRAPS, deliberately diverging from the mock's single-line
|
|
29
|
+
* ellipsis: the mock's 72px comes from its placeholder copy fitting one
|
|
30
|
+
* line, while real wire text is a full sentence, and clipping the only
|
|
31
|
+
* explanation of a lifecycle change is not recoverable on touch. The title
|
|
32
|
+
* still truncates — it holds one line so the timestamp is never pushed out
|
|
33
|
+
* of the row at narrow widths.
|
|
34
|
+
*/
|
|
35
|
+
const AiAssistantInfo = forwardRef<HTMLDivElement, AiAssistantInfoProps>(
|
|
36
|
+
({ className, icon, leading, title, body, timestamp, ...props }, ref) => {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
ref={ref}
|
|
40
|
+
className={cn(
|
|
41
|
+
"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)]",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
{leading ?? (
|
|
47
|
+
<div className="flex size-12 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-bg">
|
|
48
|
+
{icon}
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
52
|
+
<div className="flex w-full items-start">
|
|
53
|
+
<p className="text-h4 min-w-0 flex-1 truncate text-ods-text-primary" title={title}>
|
|
54
|
+
{title}
|
|
55
|
+
</p>
|
|
56
|
+
{timestamp && (
|
|
57
|
+
<span className="text-h6 shrink-0 text-right text-ods-text-secondary">{formatTime(timestamp)}</span>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
{body != null && body !== "" && (
|
|
61
|
+
<p className="text-h4 w-full break-words text-ods-text-secondary">{body}</p>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
AiAssistantInfo.displayName = "AiAssistantInfo"
|
|
70
|
+
|
|
71
|
+
export { AiAssistantInfo }
|
|
@@ -10,6 +10,7 @@ import { ApprovalRequestMessage } from "./approval-request-message"
|
|
|
10
10
|
import { ApprovalBatchMessage } from "./approval-batch-message"
|
|
11
11
|
import { EscalationOfferMessage } from "./escalation-offer-message"
|
|
12
12
|
import { TicketEscalatedMessage } from "./ticket-escalated-message"
|
|
13
|
+
import { TicketEventMessage } from "./ticket-event-message"
|
|
13
14
|
import { ErrorMessageDisplay } from "./error-message-display"
|
|
14
15
|
import { ContextCompactionDisplay } from "./context-compaction-display"
|
|
15
16
|
import { ThinkingDisplay } from "./thinking-display"
|
|
@@ -650,6 +651,8 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
650
651
|
)
|
|
651
652
|
} else if (segment.type === 'ticket_escalated') {
|
|
652
653
|
return <TicketEscalatedMessage key={index} data={segment.data} timestamp={timestamp} />
|
|
654
|
+
} else if (segment.type === 'ticket_event') {
|
|
655
|
+
return <TicketEventMessage key={index} data={segment.data} timestamp={timestamp} />
|
|
653
656
|
} else if (segment.type === 'error') {
|
|
654
657
|
return (
|
|
655
658
|
<ErrorMessageDisplay
|
|
@@ -5,6 +5,8 @@ export * from './approval-request-message'
|
|
|
5
5
|
export * from './approval-batch-message'
|
|
6
6
|
export * from './escalation-offer-message'
|
|
7
7
|
export * from './ticket-escalated-message'
|
|
8
|
+
export * from './ticket-event-message'
|
|
9
|
+
export * from './ai-assistant-info'
|
|
8
10
|
export * from './context-compaction-display'
|
|
9
11
|
export * from './expand-chevron'
|
|
10
12
|
export * from './thinking-display'
|
|
@@ -1364,6 +1364,25 @@ export function createChatStreamReducer(
|
|
|
1364
1364
|
break
|
|
1365
1365
|
}
|
|
1366
1366
|
|
|
1367
|
+
case 'ticket-event': {
|
|
1368
|
+
// Standalone chunk outside MESSAGE_START/END — same routing as
|
|
1369
|
+
// `ticket-escalated`. `seq` rides onto the segment: it is the event's
|
|
1370
|
+
// dedupe identity (see `addTicketEvent`).
|
|
1371
|
+
const before = accumulator.getSegments().length
|
|
1372
|
+
const segments = accumulator.addTicketEvent(
|
|
1373
|
+
{
|
|
1374
|
+
kind: event.kind,
|
|
1375
|
+
actorId: event.actorId,
|
|
1376
|
+
actorName: event.actorName,
|
|
1377
|
+
actorType: event.actorType,
|
|
1378
|
+
reason: event.reason,
|
|
1379
|
+
},
|
|
1380
|
+
seq,
|
|
1381
|
+
)
|
|
1382
|
+
applyAccumulated(before, segments)
|
|
1383
|
+
break
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1367
1386
|
case 'error': {
|
|
1368
1387
|
let message: string | undefined
|
|
1369
1388
|
if (event.details) {
|
|
@@ -96,6 +96,11 @@ function upsertKey(seg: MessageSegment): string | null {
|
|
|
96
96
|
return `offer:${seg.data.offerId}`
|
|
97
97
|
case 'ticket_escalated':
|
|
98
98
|
return `escalated:${seg.data.ticketId}`
|
|
99
|
+
case 'ticket_event':
|
|
100
|
+
// The stream sequence is the event's only stable id; a seq-less segment
|
|
101
|
+
// (hydrated history) is pushed raw — `addTicketEvent`'s payload-equality
|
|
102
|
+
// upsert owns that case.
|
|
103
|
+
return seg.streamSeq !== undefined ? `ticket-event:${seg.streamSeq}` : null
|
|
99
104
|
default:
|
|
100
105
|
return null
|
|
101
106
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { forwardRef, type HTMLAttributes } from "react"
|
|
4
|
-
import { cn } from "../../utils/cn"
|
|
5
|
-
import { formatTime } from "../../utils/format-date"
|
|
6
4
|
import { InfoCircleIcon } from "../icons-v2-generated"
|
|
5
|
+
import { AiAssistantInfo } from "./ai-assistant-info"
|
|
7
6
|
import type { TicketEscalatedSegment } from "./types/message.types"
|
|
8
7
|
|
|
9
8
|
const TITLE = "Handed Off to a Technician"
|
|
@@ -19,46 +18,25 @@ export interface TicketEscalatedMessageProps extends HTMLAttributes<HTMLDivEleme
|
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* Handoff receipt — the conversation now belongs to a human technician.
|
|
22
|
-
* Figma `ai-assistant-info` (type=escalation)
|
|
21
|
+
* Figma `ai-assistant-info` (type=escalation); chrome and the wrapping/
|
|
22
|
+
* truncation trade-offs live in the shared `AiAssistantInfo` shell.
|
|
23
23
|
*
|
|
24
24
|
* The body is whatever the backend authored, so the reason it gives stays
|
|
25
25
|
* server-side; the client never composes an explanation and never branches on
|
|
26
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
27
|
*/
|
|
35
28
|
const TicketEscalatedMessage = forwardRef<HTMLDivElement, TicketEscalatedMessageProps>(
|
|
36
|
-
({
|
|
29
|
+
({ data, timestamp, ...props }, ref) => {
|
|
37
30
|
const body = data.text?.trim() || DEFAULT_BODY
|
|
38
31
|
return (
|
|
39
|
-
<
|
|
32
|
+
<AiAssistantInfo
|
|
40
33
|
ref={ref}
|
|
41
|
-
className=
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
icon={<InfoCircleIcon className="size-6 text-ods-flamingo-pink" />}
|
|
35
|
+
title={TITLE}
|
|
36
|
+
body={body}
|
|
37
|
+
timestamp={timestamp}
|
|
45
38
|
{...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>
|
|
39
|
+
/>
|
|
62
40
|
)
|
|
63
41
|
},
|
|
64
42
|
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from "react"
|
|
4
|
+
import { CheckCircleIcon, InfoCircleIcon } from "../icons-v2-generated"
|
|
5
|
+
import { AiAssistantInfo } from "./ai-assistant-info"
|
|
6
|
+
import { TICKET_EVENT_KIND, type TicketEventSegment } from "./types/message.types"
|
|
7
|
+
|
|
8
|
+
/** Actor types the backend uses for the AI agent. Open set on the wire —
|
|
9
|
+
* compared case-insensitively; anything else is treated as a human. */
|
|
10
|
+
const AI_ACTOR_TYPES = new Set(["AI", "AGENT", "ASSISTANT", "BOT"])
|
|
11
|
+
|
|
12
|
+
function isAiActor(actorType?: string): boolean {
|
|
13
|
+
return !!actorType && AI_ACTOR_TYPES.has(actorType.toUpperCase())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `TICKET_ON_HOLD` → `Ticket On Hold` — neutral title for kinds this build
|
|
17
|
+
* does not know, which must still render (the vocabulary is open). */
|
|
18
|
+
function humanizeKind(kind: string): string {
|
|
19
|
+
return kind
|
|
20
|
+
.split(/[_\s-]+/)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
23
|
+
.join(" ")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TicketEventMessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
27
|
+
data: TicketEventSegment['data']
|
|
28
|
+
/** Timestamp of the message row this block came in on. */
|
|
29
|
+
timestamp?: Date
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ticket lifecycle receipt — resolved / reopened / an unknown future kind.
|
|
34
|
+
* Figma `ai-assistant-info` (type=resolved-fae | resolved-tech |
|
|
35
|
+
* reopened-fae | reopened-tech | reopened-reason).
|
|
36
|
+
*
|
|
37
|
+
* Copy is composed client-side from the event's fields (unlike
|
|
38
|
+
* `ticket_escalated`, whose body the backend authors):
|
|
39
|
+
* RESOLVED → "Resolved by {actorName}." under a green check;
|
|
40
|
+
* REOPENED → the reason when the wire carries one, otherwise who picks the
|
|
41
|
+
* conversation up next, inferred from `actorType` (an AI actor keeps
|
|
42
|
+
* assisting; anything else means a technician will reply);
|
|
43
|
+
* unknown kind → a neutral info line: humanized kind as the title, the
|
|
44
|
+
* reason or actor as the body. Never dropped — the vocabulary is open.
|
|
45
|
+
*/
|
|
46
|
+
const TicketEventMessage = forwardRef<HTMLDivElement, TicketEventMessageProps>(
|
|
47
|
+
({ data, timestamp, ...props }, ref) => {
|
|
48
|
+
const { kind, actorName, actorType, reason } = data
|
|
49
|
+
const resolved = kind === TICKET_EVENT_KIND.RESOLVED
|
|
50
|
+
const reopened = kind === TICKET_EVENT_KIND.REOPENED
|
|
51
|
+
|
|
52
|
+
const title = resolved ? "Ticket Resolved" : reopened ? "Ticket Reopened" : humanizeKind(kind)
|
|
53
|
+
|
|
54
|
+
let body: string | undefined
|
|
55
|
+
if (resolved) {
|
|
56
|
+
body = actorName ? `Resolved by ${actorName}.` : "The ticket has been resolved."
|
|
57
|
+
} else if (reopened) {
|
|
58
|
+
body = reason
|
|
59
|
+
? `Reason: ${reason}`
|
|
60
|
+
: isAiActor(actorType)
|
|
61
|
+
? `${actorName || "Fae"} will continue assisting in this conversation.`
|
|
62
|
+
: "A technician will reply when available."
|
|
63
|
+
} else {
|
|
64
|
+
body = reason || (actorName ? `By ${actorName}.` : undefined)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const icon = resolved ? (
|
|
68
|
+
<CheckCircleIcon className="size-6 text-ods-attention-green-success" />
|
|
69
|
+
) : (
|
|
70
|
+
<InfoCircleIcon className="size-6 text-ods-flamingo-pink" />
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return <AiAssistantInfo ref={ref} icon={icon} title={title} body={body} timestamp={timestamp} {...props} />
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
TicketEventMessage.displayName = "TicketEventMessage"
|
|
78
|
+
|
|
79
|
+
export { TicketEventMessage }
|