@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.
Files changed (147) hide show
  1. package/dist/chat-protocol/events.d.ts +15 -1
  2. package/dist/chat-protocol/events.d.ts.map +1 -1
  3. package/dist/chat-protocol/index.cjs +15 -0
  4. package/dist/chat-protocol/index.cjs.map +1 -1
  5. package/dist/chat-protocol/index.js +15 -0
  6. package/dist/chat-protocol/index.js.map +1 -1
  7. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  8. package/dist/{chunk-7FXZHI5D.js → chunk-4BK5Z763.js} +2 -2
  9. package/dist/{chunk-C4LEH3VE.js → chunk-56QT3JBZ.js} +2 -2
  10. package/dist/{chunk-LFMIAPBP.js → chunk-67CKAX7Z.js} +2 -2
  11. package/dist/{chunk-CZHCXCWL.js → chunk-73W4L656.js} +2 -2
  12. package/dist/{chunk-TCSSYTDN.js → chunk-7CBQWZAH.js} +4 -4
  13. package/dist/{chunk-YIDGB5KP.js → chunk-BNF6FZLP.js} +4 -4
  14. package/dist/{chunk-CATY4DVR.cjs → chunk-BUKQ6B65.cjs} +87 -87
  15. package/dist/{chunk-CATY4DVR.cjs.map → chunk-BUKQ6B65.cjs.map} +1 -1
  16. package/dist/{chunk-KKZ2QMQD.cjs → chunk-C6776X43.cjs} +26 -26
  17. package/dist/{chunk-KKZ2QMQD.cjs.map → chunk-C6776X43.cjs.map} +1 -1
  18. package/dist/{chunk-MOIHWYSA.js → chunk-C6NKOHFW.js} +2 -2
  19. package/dist/{chunk-HRC56BQW.js → chunk-EH6LNTOT.js} +5 -5
  20. package/dist/{chunk-YPI3JIR6.cjs → chunk-F4QOOHZL.cjs} +11 -11
  21. package/dist/{chunk-YPI3JIR6.cjs.map → chunk-F4QOOHZL.cjs.map} +1 -1
  22. package/dist/{chunk-3GXMS4OP.js → chunk-KUKHUECE.js} +2 -2
  23. package/dist/{chunk-DHSVBGOX.cjs → chunk-LJW7B6GY.cjs} +9 -9
  24. package/dist/{chunk-DHSVBGOX.cjs.map → chunk-LJW7B6GY.cjs.map} +1 -1
  25. package/dist/{chunk-WJIBB3KT.cjs → chunk-MGYNZ7SA.cjs} +31 -31
  26. package/dist/{chunk-WJIBB3KT.cjs.map → chunk-MGYNZ7SA.cjs.map} +1 -1
  27. package/dist/{chunk-OYTM3YXO.cjs → chunk-NE67LT3B.cjs} +7 -7
  28. package/dist/{chunk-OYTM3YXO.cjs.map → chunk-NE67LT3B.cjs.map} +1 -1
  29. package/dist/{chunk-SKIM263K.cjs → chunk-NUHDE7CB.cjs} +37 -37
  30. package/dist/{chunk-SKIM263K.cjs.map → chunk-NUHDE7CB.cjs.map} +1 -1
  31. package/dist/{chunk-MDP3YKFI.cjs → chunk-PFGRHRBO.cjs} +5 -5
  32. package/dist/{chunk-MDP3YKFI.cjs.map → chunk-PFGRHRBO.cjs.map} +1 -1
  33. package/dist/{chunk-SGNWSUL3.js → chunk-QK4SQAYG.js} +6 -6
  34. package/dist/{chunk-YMPRMZDD.cjs → chunk-QQ3HUSDJ.cjs} +599 -328
  35. package/dist/chunk-QQ3HUSDJ.cjs.map +1 -0
  36. package/dist/{chunk-QPPG3KHW.cjs → chunk-QU6P2U2Q.cjs} +3 -3
  37. package/dist/{chunk-QPPG3KHW.cjs.map → chunk-QU6P2U2Q.cjs.map} +1 -1
  38. package/dist/{chunk-V4D5MPB7.cjs → chunk-R3XO2KO4.cjs} +4 -4
  39. package/dist/{chunk-V4D5MPB7.cjs.map → chunk-R3XO2KO4.cjs.map} +1 -1
  40. package/dist/{chunk-7EHGP4QK.js → chunk-RBBWKSRC.js} +4638 -4367
  41. package/dist/chunk-RBBWKSRC.js.map +1 -0
  42. package/dist/{chunk-VSIHO7DJ.cjs → chunk-TWVRCPWY.cjs} +61 -61
  43. package/dist/{chunk-VSIHO7DJ.cjs.map → chunk-TWVRCPWY.cjs.map} +1 -1
  44. package/dist/{chunk-CQQXB25K.js → chunk-UBEVU6LS.js} +2 -2
  45. package/dist/{chunk-7WNJ7RFO.cjs → chunk-YO5BWF4L.cjs} +14 -14
  46. package/dist/{chunk-7WNJ7RFO.cjs.map → chunk-YO5BWF4L.cjs.map} +1 -1
  47. package/dist/{chunk-WHUEUACV.js → chunk-ZBRKTF6S.js} +2 -2
  48. package/dist/{chunk-5JPSQOZQ.js → chunk-ZHW5VLHY.js} +2 -2
  49. package/dist/{chunk-YEKXTTAC.cjs → chunk-ZNWW4WAT.cjs} +26 -26
  50. package/dist/{chunk-YEKXTTAC.cjs.map → chunk-ZNWW4WAT.cjs.map} +1 -1
  51. package/dist/components/case-studies/index.cjs +8 -8
  52. package/dist/components/case-studies/index.js +2 -2
  53. package/dist/components/chat/ai-assistant-info.d.ts +31 -0
  54. package/dist/components/chat/ai-assistant-info.d.ts.map +1 -0
  55. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  56. package/dist/components/chat/index.cjs +8 -2
  57. package/dist/components/chat/index.cjs.map +1 -1
  58. package/dist/components/chat/index.d.ts +2 -0
  59. package/dist/components/chat/index.d.ts.map +1 -1
  60. package/dist/components/chat/index.js +7 -1
  61. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  62. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  63. package/dist/components/chat/ticket-escalated-message.d.ts +2 -8
  64. package/dist/components/chat/ticket-escalated-message.d.ts.map +1 -1
  65. package/dist/components/chat/ticket-event-message.d.ts +24 -0
  66. package/dist/components/chat/ticket-event-message.d.ts.map +1 -0
  67. package/dist/components/chat/types/message.types.d.ts +51 -2
  68. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  69. package/dist/components/chat/utils/message-segment-accumulator.d.ts +13 -1
  70. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  71. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  72. package/dist/components/contact/index.cjs +3 -3
  73. package/dist/components/contact/index.js +2 -2
  74. package/dist/components/docs/index.cjs +5 -5
  75. package/dist/components/docs/index.js +4 -4
  76. package/dist/components/embeds/index.cjs +3 -3
  77. package/dist/components/embeds/index.js +2 -2
  78. package/dist/components/faq/index.cjs +3 -3
  79. package/dist/components/faq/index.js +2 -2
  80. package/dist/components/features/index.cjs +2 -2
  81. package/dist/components/features/index.js +1 -1
  82. package/dist/components/help-center-pages/index.cjs +22 -22
  83. package/dist/components/help-center-pages/index.js +13 -13
  84. package/dist/components/index.cjs +144 -132
  85. package/dist/components/index.cjs.map +1 -1
  86. package/dist/components/index.js +23 -11
  87. package/dist/components/index.js.map +1 -1
  88. package/dist/components/meeting-scheduler/index.cjs +34 -34
  89. package/dist/components/meeting-scheduler/index.js +3 -3
  90. package/dist/components/navigation/index.cjs +2 -2
  91. package/dist/components/navigation/index.js +1 -1
  92. package/dist/components/onboarding-guides/index.cjs +5 -5
  93. package/dist/components/onboarding-guides/index.js +4 -4
  94. package/dist/components/related-content/index.cjs +3 -3
  95. package/dist/components/related-content/index.js +2 -2
  96. package/dist/components/tickets/index.cjs +6 -6
  97. package/dist/components/tickets/index.js +5 -5
  98. package/dist/components/ui/index.cjs +14 -2
  99. package/dist/components/ui/index.cjs.map +1 -1
  100. package/dist/components/ui/index.d.ts +1 -0
  101. package/dist/components/ui/index.d.ts.map +1 -1
  102. package/dist/components/ui/index.js +13 -1
  103. package/dist/components/ui/reopen-ticket-modal.d.ts +42 -0
  104. package/dist/components/ui/reopen-ticket-modal.d.ts.map +1 -0
  105. package/dist/components/ui/take-over-ticket-modal.d.ts +5 -0
  106. package/dist/components/ui/take-over-ticket-modal.d.ts.map +1 -1
  107. package/dist/index.cjs +14 -2
  108. package/dist/index.cjs.map +1 -1
  109. package/dist/index.js +13 -1
  110. package/dist/utils/index.cjs.map +1 -1
  111. package/dist/utils/index.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +44 -0
  114. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +32 -0
  115. package/src/chat-protocol/events.ts +16 -0
  116. package/src/chat-protocol/nats-decoder.ts +27 -0
  117. package/src/components/chat/__tests__/ticket-event.test.ts +194 -0
  118. package/src/components/chat/ai-assistant-info.tsx +71 -0
  119. package/src/components/chat/chat-message-enhanced.tsx +3 -0
  120. package/src/components/chat/index.ts +2 -0
  121. package/src/components/chat/stream/chat-stream-reducer.ts +19 -0
  122. package/src/components/chat/stream/message-mutations.ts +5 -0
  123. package/src/components/chat/ticket-escalated-message.tsx +10 -32
  124. package/src/components/chat/ticket-event-message.tsx +79 -0
  125. package/src/components/chat/types/message.types.ts +60 -1
  126. package/src/components/chat/utils/message-segment-accumulator.ts +41 -0
  127. package/src/components/chat/utils/process-historical-messages.ts +31 -0
  128. package/src/components/ui/index.ts +1 -0
  129. package/src/components/ui/reopen-ticket-modal.tsx +174 -0
  130. package/src/components/ui/take-over-ticket-modal.tsx +7 -4
  131. package/src/stories/ReopenTicketModal.stories.tsx +114 -0
  132. package/src/stories/TicketEventMessage.stories.tsx +100 -0
  133. package/dist/chunk-7EHGP4QK.js.map +0 -1
  134. package/dist/chunk-YMPRMZDD.cjs.map +0 -1
  135. /package/dist/{chunk-7FXZHI5D.js.map → chunk-4BK5Z763.js.map} +0 -0
  136. /package/dist/{chunk-C4LEH3VE.js.map → chunk-56QT3JBZ.js.map} +0 -0
  137. /package/dist/{chunk-LFMIAPBP.js.map → chunk-67CKAX7Z.js.map} +0 -0
  138. /package/dist/{chunk-CZHCXCWL.js.map → chunk-73W4L656.js.map} +0 -0
  139. /package/dist/{chunk-TCSSYTDN.js.map → chunk-7CBQWZAH.js.map} +0 -0
  140. /package/dist/{chunk-YIDGB5KP.js.map → chunk-BNF6FZLP.js.map} +0 -0
  141. /package/dist/{chunk-MOIHWYSA.js.map → chunk-C6NKOHFW.js.map} +0 -0
  142. /package/dist/{chunk-HRC56BQW.js.map → chunk-EH6LNTOT.js.map} +0 -0
  143. /package/dist/{chunk-3GXMS4OP.js.map → chunk-KUKHUECE.js.map} +0 -0
  144. /package/dist/{chunk-SGNWSUL3.js.map → chunk-QK4SQAYG.js.map} +0 -0
  145. /package/dist/{chunk-CQQXB25K.js.map → chunk-UBEVU6LS.js.map} +0 -0
  146. /package/dist/{chunk-WHUEUACV.js.map → chunk-ZBRKTF6S.js.map} +0 -0
  147. /package/dist/{chunk-5JPSQOZQ.js.map → chunk-ZHW5VLHY.js.map} +0 -0
@@ -18,6 +18,7 @@ export const MESSAGE_TYPE = {
18
18
  APPROVAL_RESULT: 'APPROVAL_RESULT',
19
19
  ESCALATION_OFFER: 'ESCALATION_OFFER',
20
20
  TICKET_ESCALATED: 'TICKET_ESCALATED',
21
+ TICKET_EVENT: 'TICKET_EVENT',
21
22
  ERROR: 'ERROR',
22
23
  MESSAGE_START: 'MESSAGE_START',
23
24
  MESSAGE_END: 'MESSAGE_END',
@@ -158,6 +159,34 @@ export interface TicketEscalatedData {
158
159
  text?: string
159
160
  }
160
161
 
162
+ // ========== Ticket Event Types ==========
163
+
164
+ /** Known `TicketEventData.kind` values. The vocabulary is OPEN on the wire:
165
+ * an unknown kind still renders (as a neutral info line) rather than being
166
+ * dropped, so the backend can add lifecycle kinds without a client release. */
167
+ export const TICKET_EVENT_KIND = {
168
+ RESOLVED: 'RESOLVED',
169
+ REOPENED: 'REOPENED',
170
+ } as const
171
+
172
+ export type TicketEventKind =
173
+ | typeof TICKET_EVENT_KIND[keyof typeof TICKET_EVENT_KIND]
174
+ | (string & {})
175
+
176
+ /** Ticket lifecycle receipt (`TICKET_EVENT`) — the ticket was resolved,
177
+ * reopened, etc. Same field names live (NATS chunk) and persisted
178
+ * (GraphQL `TicketEventData` inside `messageData`), so ONE mapper covers
179
+ * both paths. All actor fields are optional: the event stays renderable
180
+ * when the backend can't attribute it. */
181
+ export interface TicketEventData {
182
+ kind: TicketEventKind
183
+ actorId?: string
184
+ actorName?: string
185
+ /** Who acted — e.g. an AI agent vs a human. Open string like `kind`. */
186
+ actorType?: string
187
+ reason?: string
188
+ }
189
+
161
190
  /**
162
191
  * Single tool call inside a batch approval request.
163
192
  * Mirrors backend PendingToolCallDto.
@@ -310,6 +339,23 @@ export type TicketEscalatedSegment = {
310
339
  data: TicketEscalatedData
311
340
  }
312
341
 
342
+ /**
343
+ * Ticket lifecycle receipt (resolved / reopened / an unknown future kind).
344
+ * Arrives as a standalone `TICKET_EVENT` chunk outside MESSAGE_START/END —
345
+ * same delivery shape as `ticket_escalated`.
346
+ *
347
+ * `streamSeq` is the chunk's JetStream sequence, kept ON the segment because
348
+ * it is the event's only stable identity: the accumulator upserts on it so a
349
+ * redelivered chunk (catch-up over hydrated history) replaces rather than
350
+ * stacks a second card, while two genuinely distinct events of the same kind
351
+ * (resolve → reopen → resolve) keep distinct sequences and both render.
352
+ */
353
+ export type TicketEventSegment = {
354
+ type: 'ticket_event'
355
+ data: TicketEventData
356
+ streamSeq?: number
357
+ }
358
+
313
359
  export type ErrorSegment = {
314
360
  type: 'error'
315
361
  title: string
@@ -322,7 +368,7 @@ export type ContextCompactionSegment = {
322
368
  summary?: string
323
369
  }
324
370
 
325
- export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | AskSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | EscalationOfferSegment | TicketEscalatedSegment | ErrorSegment | ContextCompactionSegment
371
+ export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | AskSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | EscalationOfferSegment | TicketEscalatedSegment | TicketEventSegment | ErrorSegment | ContextCompactionSegment
326
372
 
327
373
  export type MessageContent = string | MessageSegment[]
328
374
 
@@ -427,6 +473,18 @@ export interface TicketEscalatedMessageData extends MessageDataBase {
427
473
  text?: string
428
474
  }
429
475
 
476
+ /** Persisted ticket lifecycle row — same field names as the live chunk
477
+ * (see `TicketEventData`), decoded by the SAME mapper on replay. Fields are
478
+ * wire-shaped (`| null`): every one is a nullable GraphQL scalar. */
479
+ export interface TicketEventMessageData extends MessageDataBase {
480
+ type: 'TICKET_EVENT'
481
+ kind?: string | null
482
+ actorId?: string | null
483
+ actorName?: string | null
484
+ actorType?: string | null
485
+ reason?: string | null
486
+ }
487
+
430
488
  export interface ErrorMessageData extends MessageDataBase {
431
489
  type: 'ERROR'
432
490
  error?: string
@@ -473,6 +531,7 @@ export type MessageData =
473
531
  | ApprovalResultMessageData
474
532
  | EscalationOfferMessageData
475
533
  | TicketEscalatedMessageData
534
+ | TicketEventMessageData
476
535
  | ErrorMessageData
477
536
  | AIMetadataMessageData
478
537
  | SystemMessageData
@@ -20,6 +20,8 @@ import type {
20
20
  EscalationOfferSegment,
21
21
  TicketEscalatedData,
22
22
  TicketEscalatedSegment,
23
+ TicketEventData,
24
+ TicketEventSegment,
23
25
  ContextCompactionSegment,
24
26
  ErrorSegment,
25
27
  PendingApproval,
@@ -535,6 +537,42 @@ export class MessageSegmentAccumulator {
535
537
  return this.getSegments()
536
538
  }
537
539
 
540
+ /**
541
+ * Add a ticket lifecycle receipt (resolved / reopened / unknown kind).
542
+ *
543
+ * Upsert identity is the chunk's stream sequence when BOTH sides know it;
544
+ * otherwise full payload equality. History-hydrated segments come back
545
+ * seq-less (the persisted row's seq lives on the message, not the
546
+ * `messageData`), so a JetStream catch-up redelivery of the same event must
547
+ * still match its hydrated twin — payload equality is what catches that.
548
+ * Two genuinely distinct events (resolve → reopen → resolve) differ in seq
549
+ * or payload and both render.
550
+ */
551
+ addTicketEvent(data: TicketEventData, streamSeq?: number): MessageSegment[] {
552
+ const segment: TicketEventSegment = {
553
+ type: 'ticket_event',
554
+ data,
555
+ ...(streamSeq !== undefined ? { streamSeq } : {}),
556
+ }
557
+ const existingIndex = this.segments.findIndex((s) => {
558
+ if (s.type !== 'ticket_event') return false
559
+ if (s.streamSeq !== undefined && streamSeq !== undefined) return s.streamSeq === streamSeq
560
+ return (
561
+ s.data.kind === data.kind &&
562
+ s.data.actorId === data.actorId &&
563
+ s.data.actorName === data.actorName &&
564
+ s.data.actorType === data.actorType &&
565
+ s.data.reason === data.reason
566
+ )
567
+ })
568
+ if (existingIndex !== -1) {
569
+ this.segments[existingIndex] = segment
570
+ return this.getSegments()
571
+ }
572
+ this.segments.push(segment)
573
+ return this.getSegments()
574
+ }
575
+
538
576
  /**
539
577
  * Update status of an existing approval segment (single, batch, or
540
578
  * escalation offer).
@@ -680,6 +718,9 @@ export class MessageSegmentAccumulator {
680
718
  case 'ticket_escalated':
681
719
  this.addTicketEscalated(segment.data)
682
720
  break
721
+ case 'ticket_event':
722
+ this.addTicketEvent(segment.data, segment.streamSeq)
723
+ break
683
724
  case 'error':
684
725
  this.addError(segment.title, segment.details)
685
726
  break
@@ -243,6 +243,23 @@ export function decodeHistoricalMessageData(data: MessageData): ChatStreamEvent
243
243
  }
244
244
  return null
245
245
 
246
+ // Same completeness gate as the live decoder: `kind` (an OPEN string —
247
+ // unknown kinds render as a neutral line) is the only required field.
248
+ // Field names match the chunk, so ONE mapping covers both paths; the
249
+ // typeof gates also fold the row's GraphQL nulls to undefined.
250
+ case MESSAGE_TYPE.TICKET_EVENT: {
251
+ const kind = 'kind' in data && typeof data.kind === 'string' ? data.kind.trim() : ''
252
+ if (!kind) return null
253
+ return {
254
+ type: 'ticket-event',
255
+ kind,
256
+ actorId: typeof data.actorId === 'string' ? data.actorId : undefined,
257
+ actorName: typeof data.actorName === 'string' ? data.actorName : undefined,
258
+ actorType: typeof data.actorType === 'string' ? data.actorType : undefined,
259
+ reason: typeof data.reason === 'string' && data.reason.trim() ? data.reason : undefined,
260
+ }
261
+ }
262
+
246
263
  case MESSAGE_TYPE.ERROR:
247
264
  if ('error' in data) {
248
265
  return {
@@ -336,6 +353,20 @@ function applyHistoryEvent(
336
353
  })
337
354
  break
338
355
 
356
+ // Seq-less on purpose: the persisted row's sequence lives on the message
357
+ // (`lastChunkStreamSeq`), not in `messageData`. A live redelivery of the
358
+ // same event matches this segment by payload equality (see
359
+ // `addTicketEvent`).
360
+ case 'ticket-event':
361
+ accumulator.addTicketEvent({
362
+ kind: event.kind,
363
+ actorId: event.actorId,
364
+ actorName: event.actorName,
365
+ actorType: event.actorType,
366
+ reason: event.reason,
367
+ })
368
+ break
369
+
339
370
  case 'escalation-offer-resolved':
340
371
  // Recorded, not applied here: `applyOfferResolutions` runs after every
341
372
  // flush and covers the same-bubble case as well as the cross-bubble one.
@@ -179,6 +179,7 @@ export * from './test-run'
179
179
  export * from './assignee-dropdown'
180
180
  export * from './ticket-info-section'
181
181
  export * from './take-over-ticket-modal'
182
+ export * from './reopen-ticket-modal'
182
183
  export * from './ticket-detail-section'
183
184
  export * from './ticket-attachments-list'
184
185
  export * from './ticket-note-card'
@@ -0,0 +1,174 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Autocomplete } from './autocomplete';
5
+ import { Button } from './button';
6
+ import { ColorSwatch } from './color-swatch';
7
+ import { ModalV2, ModalV2Footer, ModalV2Header, ModalV2Title } from './modal-v2';
8
+ import { SquareAvatar } from './square-avatar';
9
+ import {
10
+ renderTicketAssigneeOption,
11
+ renderTicketStatusOption,
12
+ type TakeOverAssigneeOption,
13
+ type TakeOverStatusOption,
14
+ } from './take-over-ticket-modal';
15
+ import { Textarea } from './textarea';
16
+
17
+ /** Backend trims and caps the reason at 1000 chars; cap it client-side too. */
18
+ const REASON_MAX_LENGTH = 1000;
19
+
20
+ export interface ReopenTicketSelection {
21
+ statusId: string;
22
+ /** `null` — reopen without an assignee (the ticket stays unassigned). */
23
+ assigneeId: string | null;
24
+ /** Trimmed; `null` when the field was left blank. */
25
+ reason: string | null;
26
+ }
27
+
28
+ export interface ReopenTicketModalProps {
29
+ isOpen: boolean;
30
+ onClose: () => void;
31
+ /** Highlighted ticket reference in the description, e.g. "1003: Email client synchronization issues". */
32
+ ticketRef: string;
33
+ /** Allowed target statuses, in display order. Per design, the consumer
34
+ * pre-selects Tech Required by default (annotation on the mock: "tech
35
+ * required by default, but may be changed to custom status"). */
36
+ statusOptions: TakeOverStatusOption[];
37
+ /** Assignable users, in display order (consumer decides ordering, e.g. signed-in user first). */
38
+ assigneeOptions: TakeOverAssigneeOption[];
39
+ assigneesLoading?: boolean;
40
+ /** Pre-selected status; falls back to the first status option. */
41
+ initialStatusId?: string | null;
42
+ /** Pre-selected assignee. Unlike Take Over, the assignee is OPTIONAL —
43
+ * a reopened ticket may stay unassigned (it is flagged for triage). */
44
+ initialAssigneeId?: string | null;
45
+ /** Confirm in-flight: buttons lock and the modal refuses to close. */
46
+ isPending?: boolean;
47
+ onConfirm: (selection: ReopenTicketSelection) => void;
48
+ }
49
+
50
+ /**
51
+ * Reopen Ticket dialog (Figma openframe---tickets 8456-17581): shown when a
52
+ * technician reopens a Resolved/Archived ticket. Same Status + Assigned pair
53
+ * as {@link TakeOverTicketModal} plus an optional free-text reason.
54
+ * Presentational — the consumer supplies the option lists, their ordering and
55
+ * default selections, and performs the actual reopen in `onConfirm`.
56
+ *
57
+ * Desktop footer shows only the accent Reopen button (X closes); mobile
58
+ * bottom-sheet adds a Cancel button, matching Take Over.
59
+ */
60
+ export function ReopenTicketModal({
61
+ isOpen,
62
+ onClose,
63
+ ticketRef,
64
+ statusOptions,
65
+ assigneeOptions,
66
+ assigneesLoading,
67
+ initialStatusId,
68
+ initialAssigneeId,
69
+ isPending = false,
70
+ onConfirm,
71
+ }: ReopenTicketModalProps) {
72
+ const [statusId, setStatusId] = React.useState<string | null>(null);
73
+ const [assigneeId, setAssigneeId] = React.useState<string | null>(null);
74
+ const [reason, setReason] = React.useState('');
75
+
76
+ // Re-seed the selections every time the modal opens.
77
+ React.useEffect(() => {
78
+ if (!isOpen) return;
79
+ setStatusId(initialStatusId ?? null);
80
+ setAssigneeId(initialAssigneeId ?? null);
81
+ setReason('');
82
+ }, [isOpen, initialStatusId, initialAssigneeId]);
83
+
84
+ const selectedStatusId = statusId ?? statusOptions[0]?.value ?? null;
85
+ const selectedStatus = statusOptions.find(o => o.value === selectedStatusId);
86
+ const selectedAssignee = assigneeOptions.find(o => o.value === assigneeId);
87
+
88
+ const handleConfirm = () => {
89
+ if (!selectedStatusId || isPending) return;
90
+ const trimmedReason = reason.trim();
91
+ onConfirm({
92
+ statusId: selectedStatusId,
93
+ assigneeId: assigneeId ?? null,
94
+ reason: trimmedReason ? trimmedReason.slice(0, REASON_MAX_LENGTH) : null,
95
+ });
96
+ };
97
+
98
+ return (
99
+ <ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
100
+ <ModalV2Header>
101
+ <ModalV2Title>Reopen Ticket</ModalV2Title>
102
+ </ModalV2Header>
103
+
104
+ <p className="text-h4 text-ods-text-primary">
105
+ The ticket <span className="text-ods-accent">{ticketRef}</span> will return to the selected status and appear on
106
+ the board. Assigned admins and the user will be notified.
107
+ </p>
108
+
109
+ <div className="flex flex-col md:flex-row gap-[var(--spacing-system-l)] w-full">
110
+ <div className="flex-1 min-w-0">
111
+ <Autocomplete
112
+ label="Status"
113
+ options={statusOptions}
114
+ value={selectedStatusId}
115
+ onChange={setStatusId}
116
+ placeholder="Select Status"
117
+ startAdornment={selectedStatus ? <ColorSwatch color={selectedStatus.color} /> : undefined}
118
+ renderOption={renderTicketStatusOption}
119
+ />
120
+ </div>
121
+ <div className="flex-1 min-w-0">
122
+ <Autocomplete
123
+ label="Assigned"
124
+ options={assigneeOptions}
125
+ value={assigneeId}
126
+ onChange={setAssigneeId}
127
+ placeholder="Select Technician"
128
+ loading={assigneesLoading}
129
+ startAdornment={
130
+ selectedAssignee ? (
131
+ <SquareAvatar
132
+ src={selectedAssignee.imageUrl}
133
+ alt={selectedAssignee.label}
134
+ fallback={selectedAssignee.label}
135
+ size="sm"
136
+ variant="round"
137
+ />
138
+ ) : undefined
139
+ }
140
+ renderOption={renderTicketAssigneeOption}
141
+ />
142
+ </div>
143
+ </div>
144
+
145
+ <div className="w-full">
146
+ <Textarea
147
+ label="Reason"
148
+ value={reason}
149
+ onChange={event => setReason(event.target.value)}
150
+ placeholder="Describe what's still not working (optional)"
151
+ maxLength={REASON_MAX_LENGTH}
152
+ disabled={isPending}
153
+ />
154
+ </div>
155
+
156
+ <ModalV2Footer>
157
+ <Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
158
+ Cancel
159
+ </Button>
160
+ <div className="hidden md:block flex-1" />
161
+ <Button
162
+ type="button"
163
+ variant="accent"
164
+ onClick={handleConfirm}
165
+ loading={isPending}
166
+ disabled={!selectedStatusId}
167
+ className="flex-1"
168
+ >
169
+ Reopen Ticket
170
+ </Button>
171
+ </ModalV2Footer>
172
+ </ModalV2>
173
+ );
174
+ }
@@ -42,7 +42,9 @@ export interface TakeOverTicketModalProps {
42
42
  onConfirm: (selection: TakeOverTicketSelection) => void;
43
43
  }
44
44
 
45
- function renderStatusOption(option: AutocompleteOption, isSelected: boolean): React.ReactNode {
45
+ /** Status row with color swatch — shared by the ticket lifecycle modals
46
+ * (Take Over / Reopen), which present the same Status + Assigned pair. */
47
+ export function renderTicketStatusOption(option: AutocompleteOption, isSelected: boolean): React.ReactNode {
46
48
  const { label, color } = option as TakeOverStatusOption;
47
49
  return (
48
50
  <div className="flex items-center justify-between w-full min-w-0 gap-[var(--spacing-system-xs)]">
@@ -57,7 +59,8 @@ function renderStatusOption(option: AutocompleteOption, isSelected: boolean): Re
57
59
  );
58
60
  }
59
61
 
60
- function renderAssigneeOption(option: AutocompleteOption, isSelected: boolean): React.ReactNode {
62
+ /** Assignee row with round avatar — shared by the ticket lifecycle modals. */
63
+ export function renderTicketAssigneeOption(option: AutocompleteOption, isSelected: boolean): React.ReactNode {
61
64
  const { label, imageUrl } = option as TakeOverAssigneeOption;
62
65
  return (
63
66
  <div className="flex items-center justify-between w-full min-w-0 gap-[var(--spacing-system-xs)]">
@@ -135,7 +138,7 @@ export function TakeOverTicketModal({
135
138
  onChange={setStatusId}
136
139
  placeholder="Select Status"
137
140
  startAdornment={selectedStatus ? <ColorSwatch color={selectedStatus.color} /> : undefined}
138
- renderOption={renderStatusOption}
141
+ renderOption={renderTicketStatusOption}
139
142
  />
140
143
  </div>
141
144
  <div className="flex-1 min-w-0">
@@ -157,7 +160,7 @@ export function TakeOverTicketModal({
157
160
  />
158
161
  ) : undefined
159
162
  }
160
- renderOption={renderAssigneeOption}
163
+ renderOption={renderTicketAssigneeOption}
161
164
  />
162
165
  </div>
163
166
  </div>
@@ -0,0 +1,114 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import { useState } from 'react';
3
+ import { fn } from 'storybook/test';
4
+ import { Button } from '../components/ui/button';
5
+ import {
6
+ ReopenTicketModal,
7
+ type ReopenTicketModalProps,
8
+ type ReopenTicketSelection,
9
+ } from '../components/ui/reopen-ticket-modal';
10
+
11
+ const meta = {
12
+ title: 'UI/ReopenTicketModal',
13
+ component: ReopenTicketModal,
14
+ parameters: {
15
+ layout: 'centered',
16
+ docs: {
17
+ description: {
18
+ component:
19
+ 'Reopen Ticket dialog (Figma openframe---tickets 8456-17581): shown when a technician reopens a ' +
20
+ 'Resolved/Archived ticket. Same Status + Assigned pair as TakeOverTicketModal plus an optional free-text ' +
21
+ 'reason. Unlike Take Over, the assignee is optional - a reopened ticket may stay unassigned. ' +
22
+ 'Presentational - the consumer supplies option lists (design default pre-selects Tech Required), ' +
23
+ 'default selections, and performs the actual reopen in onConfirm.',
24
+ },
25
+ },
26
+ },
27
+ argTypes: {
28
+ isPending: { control: 'boolean' },
29
+ assigneesLoading: { control: 'boolean' },
30
+ },
31
+ } satisfies Meta<typeof ReopenTicketModal>;
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof meta>;
35
+
36
+ const STATUS_OPTIONS = [
37
+ { value: 'todo', label: 'Todo', color: '#2890fa' },
38
+ { value: 'in-progress', label: 'In Progress', color: '#ffc008' },
39
+ { value: 'tech-required', label: 'Technician Required', color: '#f36666' },
40
+ ];
41
+
42
+ const ASSIGNEE_OPTIONS = [
43
+ { value: 'u1', label: 'Roman Smith' },
44
+ { value: 'u2', label: 'Olivia Carter' },
45
+ { value: 'u3', label: 'James Wilson' },
46
+ ];
47
+
48
+ function ControlledModal(props: Partial<ReopenTicketModalProps> & { pendingOnConfirm?: boolean }) {
49
+ const [isOpen, setIsOpen] = useState(true);
50
+ const [isPending, setIsPending] = useState(false);
51
+
52
+ const handleConfirm = (selection: ReopenTicketSelection) => {
53
+ props.onConfirm?.(selection);
54
+ if (!props.pendingOnConfirm) {
55
+ setIsOpen(false);
56
+ return;
57
+ }
58
+ // Simulate the consumer's reopen mutation round-trip.
59
+ setIsPending(true);
60
+ setTimeout(() => {
61
+ setIsPending(false);
62
+ setIsOpen(false);
63
+ }, 1500);
64
+ };
65
+
66
+ return (
67
+ <>
68
+ <Button variant="outline" onClick={() => setIsOpen(true)}>
69
+ Reopen Ticket
70
+ </Button>
71
+ <ReopenTicketModal
72
+ isOpen={isOpen}
73
+ onClose={() => setIsOpen(false)}
74
+ ticketRef="1003: Email client synchronization issues"
75
+ statusOptions={STATUS_OPTIONS}
76
+ assigneeOptions={ASSIGNEE_OPTIONS}
77
+ {...props}
78
+ isPending={props.isPending ?? isPending}
79
+ onConfirm={handleConfirm}
80
+ />
81
+ </>
82
+ );
83
+ }
84
+
85
+ /** Design default: Tech Required pre-selected (mock annotation), previous assignee restored. */
86
+ export const Default: Story = {
87
+ args: {
88
+ isOpen: true,
89
+ onClose: fn(),
90
+ onConfirm: fn(),
91
+ ticketRef: '1003: Email client synchronization issues',
92
+ statusOptions: STATUS_OPTIONS,
93
+ assigneeOptions: ASSIGNEE_OPTIONS,
94
+ initialStatusId: 'tech-required',
95
+ initialAssigneeId: 'u1',
96
+ },
97
+ render: args => <ControlledModal {...args} />,
98
+ };
99
+
100
+ /**
101
+ * No assignee - the reopen still confirms and the ticket stays unassigned
102
+ * (flagged for triage). This is the deliberate difference from Take Over,
103
+ * where the CTA locks until a technician is picked.
104
+ */
105
+ export const NoAssignee: Story = {
106
+ args: { ...Default.args, initialAssigneeId: null },
107
+ render: args => <ControlledModal {...args} />,
108
+ };
109
+
110
+ /** Confirm shows the pending state (buttons lock, modal refuses to close). */
111
+ export const PendingOnConfirm: Story = {
112
+ args: { ...Default.args },
113
+ render: args => <ControlledModal {...args} pendingOnConfirm />,
114
+ };
@@ -0,0 +1,100 @@
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
+ };