@flamingo-stack/openframe-frontend-core 0.0.537-snapshot.20260814101439 → 0.0.537
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 +1 -15
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +0 -15
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +0 -15
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-FDKMA7H4.js → chunk-3GXMS4OP.js} +2 -2
- package/dist/{chunk-O56ABJ43.js → chunk-5JPSQOZQ.js} +2 -2
- package/dist/{chunk-YXKFS6XF.js → chunk-7EHGP4QK.js} +4094 -4250
- package/dist/chunk-7EHGP4QK.js.map +1 -0
- package/dist/{chunk-MXB347SP.js → chunk-7FXZHI5D.js} +2 -2
- package/dist/{chunk-LR7NVXH7.cjs → chunk-7WNJ7RFO.cjs} +14 -14
- package/dist/{chunk-LR7NVXH7.cjs.map → chunk-7WNJ7RFO.cjs.map} +1 -1
- package/dist/{chunk-5CA7WS2F.js → chunk-C4LEH3VE.js} +2 -2
- package/dist/{chunk-KAQG3TDR.cjs → chunk-CATY4DVR.cjs} +87 -87
- package/dist/{chunk-KAQG3TDR.cjs.map → chunk-CATY4DVR.cjs.map} +1 -1
- package/dist/{chunk-ODENAPJ4.js → chunk-CQQXB25K.js} +2 -2
- package/dist/{chunk-APQOHCJH.js → chunk-CZHCXCWL.js} +2 -2
- package/dist/{chunk-F3PJTTFA.cjs → chunk-DHSVBGOX.cjs} +9 -9
- package/dist/{chunk-F3PJTTFA.cjs.map → chunk-DHSVBGOX.cjs.map} +1 -1
- package/dist/{chunk-YMBPNIRC.js → chunk-HRC56BQW.js} +5 -5
- package/dist/{chunk-QODV5KPW.cjs → chunk-KKZ2QMQD.cjs} +26 -26
- package/dist/{chunk-QODV5KPW.cjs.map → chunk-KKZ2QMQD.cjs.map} +1 -1
- package/dist/{chunk-DQAEKYH2.js → chunk-LFMIAPBP.js} +2 -2
- package/dist/{chunk-NX3RTMFX.cjs → chunk-MDP3YKFI.cjs} +5 -5
- package/dist/{chunk-NX3RTMFX.cjs.map → chunk-MDP3YKFI.cjs.map} +1 -1
- package/dist/{chunk-M6Q6BD4I.js → chunk-MOIHWYSA.js} +2 -2
- package/dist/{chunk-3NXYQJBY.cjs → chunk-OYTM3YXO.cjs} +7 -7
- package/dist/{chunk-3NXYQJBY.cjs.map → chunk-OYTM3YXO.cjs.map} +1 -1
- package/dist/{chunk-WPXQ2UBV.cjs → chunk-QPPG3KHW.cjs} +3 -3
- package/dist/{chunk-WPXQ2UBV.cjs.map → chunk-QPPG3KHW.cjs.map} +1 -1
- package/dist/{chunk-OPFMSQ5Q.js → chunk-SGNWSUL3.js} +6 -6
- package/dist/{chunk-OTDF4VUJ.cjs → chunk-SKIM263K.cjs} +37 -37
- package/dist/{chunk-OTDF4VUJ.cjs.map → chunk-SKIM263K.cjs.map} +1 -1
- package/dist/{chunk-QY7FHRWF.js → chunk-TCSSYTDN.js} +4 -4
- package/dist/{chunk-PT34VDFM.cjs → chunk-V4D5MPB7.cjs} +4 -4
- package/dist/{chunk-PT34VDFM.cjs.map → chunk-V4D5MPB7.cjs.map} +1 -1
- package/dist/{chunk-UV5LYSUJ.cjs → chunk-VSIHO7DJ.cjs} +61 -61
- package/dist/{chunk-UV5LYSUJ.cjs.map → chunk-VSIHO7DJ.cjs.map} +1 -1
- package/dist/{chunk-SCW6UZ3F.js → chunk-WHUEUACV.js} +2 -2
- package/dist/{chunk-64GAFOBF.cjs → chunk-WJIBB3KT.cjs} +31 -31
- package/dist/{chunk-64GAFOBF.cjs.map → chunk-WJIBB3KT.cjs.map} +1 -1
- package/dist/{chunk-W3MCQKDV.cjs → chunk-YEKXTTAC.cjs} +26 -26
- package/dist/{chunk-W3MCQKDV.cjs.map → chunk-YEKXTTAC.cjs.map} +1 -1
- package/dist/{chunk-4OHZCP7L.js → chunk-YIDGB5KP.js} +4 -4
- package/dist/{chunk-FQ53MYZ4.cjs → chunk-YMPRMZDD.cjs} +37 -193
- package/dist/chunk-YMPRMZDD.cjs.map +1 -0
- package/dist/{chunk-K5RWDOAC.cjs → chunk-YPI3JIR6.cjs} +11 -11
- package/dist/{chunk-K5RWDOAC.cjs.map → chunk-YPI3JIR6.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/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -8
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +0 -2
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +1 -7
- 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 +8 -2
- package/dist/components/chat/ticket-escalated-message.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +2 -51
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +1 -13
- 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 +132 -138
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +11 -17
- 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 +2 -8
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +1 -7
- package/dist/index.cjs +2 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -7
- 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 +0 -44
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -32
- package/src/chat-protocol/events.ts +0 -16
- package/src/chat-protocol/nats-decoder.ts +0 -27
- package/src/components/chat/chat-message-enhanced.tsx +0 -3
- package/src/components/chat/index.ts +0 -2
- package/src/components/chat/stream/chat-stream-reducer.ts +0 -19
- package/src/components/chat/stream/message-mutations.ts +0 -5
- package/src/components/chat/ticket-escalated-message.tsx +32 -10
- package/src/components/chat/types/message.types.ts +1 -60
- package/src/components/chat/utils/message-segment-accumulator.ts +0 -41
- package/src/components/chat/utils/process-historical-messages.ts +0 -31
- package/dist/chunk-FQ53MYZ4.cjs.map +0 -1
- package/dist/chunk-YXKFS6XF.js.map +0 -1
- package/dist/components/chat/ai-assistant-info.d.ts +0 -31
- package/dist/components/chat/ai-assistant-info.d.ts.map +0 -1
- package/dist/components/chat/ticket-event-message.d.ts +0 -24
- package/dist/components/chat/ticket-event-message.d.ts.map +0 -1
- package/src/components/chat/__tests__/ticket-event.test.ts +0 -194
- package/src/components/chat/ai-assistant-info.tsx +0 -71
- package/src/components/chat/ticket-event-message.tsx +0 -79
- package/src/stories/TicketEventMessage.stories.tsx +0 -100
- /package/dist/{chunk-FDKMA7H4.js.map → chunk-3GXMS4OP.js.map} +0 -0
- /package/dist/{chunk-O56ABJ43.js.map → chunk-5JPSQOZQ.js.map} +0 -0
- /package/dist/{chunk-MXB347SP.js.map → chunk-7FXZHI5D.js.map} +0 -0
- /package/dist/{chunk-5CA7WS2F.js.map → chunk-C4LEH3VE.js.map} +0 -0
- /package/dist/{chunk-ODENAPJ4.js.map → chunk-CQQXB25K.js.map} +0 -0
- /package/dist/{chunk-APQOHCJH.js.map → chunk-CZHCXCWL.js.map} +0 -0
- /package/dist/{chunk-YMBPNIRC.js.map → chunk-HRC56BQW.js.map} +0 -0
- /package/dist/{chunk-DQAEKYH2.js.map → chunk-LFMIAPBP.js.map} +0 -0
- /package/dist/{chunk-M6Q6BD4I.js.map → chunk-MOIHWYSA.js.map} +0 -0
- /package/dist/{chunk-OPFMSQ5Q.js.map → chunk-SGNWSUL3.js.map} +0 -0
- /package/dist/{chunk-QY7FHRWF.js.map → chunk-TCSSYTDN.js.map} +0 -0
- /package/dist/{chunk-SCW6UZ3F.js.map → chunk-WHUEUACV.js.map} +0 -0
- /package/dist/{chunk-4OHZCP7L.js.map → chunk-YIDGB5KP.js.map} +0 -0
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
-
export interface AiAssistantInfoProps extends HTMLAttributes<HTMLDivElement> {
|
|
3
|
-
/** Rendered inside the standard 48px framed box (typically a 24px icon). */
|
|
4
|
-
icon?: ReactNode;
|
|
5
|
-
/** Replaces the framed icon box entirely (e.g. a round 48px avatar for the
|
|
6
|
-
* `direct-chat` variant). Wins over `icon` when both are set. */
|
|
7
|
-
leading?: ReactNode;
|
|
8
|
-
title: string;
|
|
9
|
-
/** Secondary line under the title. Omitted → the row is title-only. */
|
|
10
|
-
body?: ReactNode;
|
|
11
|
-
/** Timestamp of the message row this block came in on. */
|
|
12
|
-
timestamp?: Date;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Figma `ai-assistant-info` — the shared in-thread status card: a framed
|
|
16
|
-
* icon (or avatar), a title with a right-aligned time, and a secondary line.
|
|
17
|
-
* One presentational shell for every lifecycle receipt in a chat thread
|
|
18
|
-
* (handoff, technician joined, ticket resolved/reopened, …); the per-event
|
|
19
|
-
* components (`TicketEscalatedMessage`, `TicketEventMessage`) own the copy
|
|
20
|
-
* and icon choice and delegate the chrome here.
|
|
21
|
-
*
|
|
22
|
-
* The body WRAPS, deliberately diverging from the mock's single-line
|
|
23
|
-
* ellipsis: the mock's 72px comes from its placeholder copy fitting one
|
|
24
|
-
* line, while real wire text is a full sentence, and clipping the only
|
|
25
|
-
* explanation of a lifecycle change is not recoverable on touch. The title
|
|
26
|
-
* still truncates — it holds one line so the timestamp is never pushed out
|
|
27
|
-
* of the row at narrow widths.
|
|
28
|
-
*/
|
|
29
|
-
declare const AiAssistantInfo: import("react").ForwardRefExoticComponent<AiAssistantInfoProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
30
|
-
export { AiAssistantInfo };
|
|
31
|
-
//# sourceMappingURL=ai-assistant-info.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ai-assistant-info.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ai-assistant-info.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAIvE,MAAM,WAAW,oBAAqB,SAAQ,cAAc,CAAC,cAAc,CAAC;IAC1E,4EAA4E;IAC5E,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB;sEACkE;IAClE,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,uEAAuE;IACvE,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,IAAI,CAAA;CACjB;AAED;;;;;;;;;;;;;;GAcG;AACH,QAAA,MAAM,eAAe,iHAgCpB,CAAA;AAID,OAAO,EAAE,eAAe,EAAE,CAAA"}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { type HTMLAttributes } from "react";
|
|
2
|
-
import { type TicketEventSegment } from "./types/message.types";
|
|
3
|
-
export interface TicketEventMessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
4
|
-
data: TicketEventSegment['data'];
|
|
5
|
-
/** Timestamp of the message row this block came in on. */
|
|
6
|
-
timestamp?: Date;
|
|
7
|
-
}
|
|
8
|
-
/**
|
|
9
|
-
* Ticket lifecycle receipt — resolved / reopened / an unknown future kind.
|
|
10
|
-
* Figma `ai-assistant-info` (type=resolved-fae | resolved-tech |
|
|
11
|
-
* reopened-fae | reopened-tech | reopened-reason).
|
|
12
|
-
*
|
|
13
|
-
* Copy is composed client-side from the event's fields (unlike
|
|
14
|
-
* `ticket_escalated`, whose body the backend authors):
|
|
15
|
-
* RESOLVED → "Resolved by {actorName}." under a green check;
|
|
16
|
-
* REOPENED → the reason when the wire carries one, otherwise who picks the
|
|
17
|
-
* conversation up next, inferred from `actorType` (an AI actor keeps
|
|
18
|
-
* assisting; anything else means a technician will reply);
|
|
19
|
-
* unknown kind → a neutral info line: humanized kind as the title, the
|
|
20
|
-
* reason or actor as the body. Never dropped — the vocabulary is open.
|
|
21
|
-
*/
|
|
22
|
-
declare const TicketEventMessage: import("react").ForwardRefExoticComponent<TicketEventMessageProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
23
|
-
export { TicketEventMessage };
|
|
24
|
-
//# sourceMappingURL=ticket-event-message.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ticket-event-message.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ticket-event-message.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,OAAO,CAAA;AAGvD,OAAO,EAAqB,KAAK,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAoBlF,MAAM,WAAW,uBAAwB,SAAQ,cAAc,CAAC,cAAc,CAAC;IAC7E,IAAI,EAAE,kBAAkB,CAAC,MAAM,CAAC,CAAA;IAChC,0DAA0D;IAC1D,SAAS,CAAC,EAAE,IAAI,CAAA;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,QAAA,MAAM,kBAAkB,oHA6BvB,CAAA;AAID,OAAO,EAAE,kBAAkB,EAAE,CAAA"}
|
|
@@ -1,194 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,71 +0,0 @@
|
|
|
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 }
|
|
@@ -1,79 +0,0 @@
|
|
|
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 }
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
|
|
2
|
-
import type React from "react";
|
|
3
|
-
|
|
4
|
-
import { TicketEventMessage } from "../components/chat/ticket-event-message";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* `ticket_event` message segment — the ticket lifecycle receipt (Figma:
|
|
8
|
-
* `ai-assistant-info`, types resolved-fae / resolved-tech / reopened-fae /
|
|
9
|
-
* reopened-tech / reopened-reason).
|
|
10
|
-
*
|
|
11
|
-
* Decoded from the standalone `TICKET_EVENT` chunk (live) or the persisted
|
|
12
|
-
* `TicketEventData` row (history). `kind` is an OPEN vocabulary: unknown
|
|
13
|
-
* kinds render as a neutral info line rather than being dropped, so the
|
|
14
|
-
* backend can add lifecycle kinds without a client release.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** Fixed so the timestamp doesn't churn between snapshots. */
|
|
18
|
-
const TIMESTAMP = new Date("2026-08-07T14:47:00");
|
|
19
|
-
|
|
20
|
-
const plainDecorator = (Story: React.ComponentType) => (
|
|
21
|
-
<div style={{ maxWidth: 600, background: "var(--color-bg)" }} className="p-4">
|
|
22
|
-
<Story />
|
|
23
|
-
</div>
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
const meta = {
|
|
27
|
-
title: "Chat/Client/TicketEventMessage",
|
|
28
|
-
component: TicketEventMessage,
|
|
29
|
-
tags: ["autodocs"],
|
|
30
|
-
parameters: {
|
|
31
|
-
docs: {
|
|
32
|
-
description: {
|
|
33
|
-
component:
|
|
34
|
-
"Renders a `TICKET_EVENT` block: a green check for RESOLVED, a pink info tile for REOPENED and for any kind this build doesn't know, with copy composed from the event's actor and reason fields.",
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
decorators: [plainDecorator],
|
|
39
|
-
} satisfies Meta<typeof TicketEventMessage>;
|
|
40
|
-
|
|
41
|
-
export default meta;
|
|
42
|
-
type Story = StoryObj<typeof meta>;
|
|
43
|
-
|
|
44
|
-
/** Fae (the AI agent) resolved the ticket — Figma type=resolved-fae. */
|
|
45
|
-
export const ResolvedByFae: Story = {
|
|
46
|
-
args: {
|
|
47
|
-
data: { kind: "RESOLVED", actorId: "fae", actorName: "Fae", actorType: "AI" },
|
|
48
|
-
timestamp: TIMESTAMP,
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** A human technician resolved the ticket — Figma type=resolved-tech. */
|
|
53
|
-
export const ResolvedByTechnician: Story = {
|
|
54
|
-
args: {
|
|
55
|
-
data: { kind: "RESOLVED", actorId: "u-42", actorName: "Roman Smith", actorType: "TECHNICIAN" },
|
|
56
|
-
timestamp: TIMESTAMP,
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/** Reopened with the user's stated reason — Figma type=reopened-reason. */
|
|
61
|
-
export const ReopenedWithReason: Story = {
|
|
62
|
-
args: {
|
|
63
|
-
data: {
|
|
64
|
-
kind: "REOPENED",
|
|
65
|
-
actorId: "u-7",
|
|
66
|
-
actorName: "John Smith",
|
|
67
|
-
actorType: "CLIENT",
|
|
68
|
-
reason: "The printer stopped working again",
|
|
69
|
-
},
|
|
70
|
-
timestamp: TIMESTAMP,
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
/** Reopened, no reason, AI keeps assisting — Figma type=reopened-fae. */
|
|
75
|
-
export const ReopenedFaeContinues: Story = {
|
|
76
|
-
args: {
|
|
77
|
-
data: { kind: "REOPENED", actorId: "fae", actorName: "Fae", actorType: "AI" },
|
|
78
|
-
timestamp: TIMESTAMP,
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/** Reopened, no reason, routed to a human — Figma type=reopened-tech. */
|
|
83
|
-
export const ReopenedTechnicianReplies: Story = {
|
|
84
|
-
args: {
|
|
85
|
-
data: { kind: "REOPENED", actorId: "u-42", actorName: "Roman Smith", actorType: "TECHNICIAN" },
|
|
86
|
-
timestamp: TIMESTAMP,
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* A kind the client has never seen. The open vocabulary means the backend can
|
|
92
|
-
* add lifecycle kinds without a client release — this story is the regression
|
|
93
|
-
* guard: neutral info line, humanized title, never dropped.
|
|
94
|
-
*/
|
|
95
|
-
export const UnknownKind: Story = {
|
|
96
|
-
args: {
|
|
97
|
-
data: { kind: "ON_HOLD", actorName: "Roman Smith", reason: "Waiting for replacement hardware" },
|
|
98
|
-
timestamp: TIMESTAMP,
|
|
99
|
-
},
|
|
100
|
-
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|