@flamingo-stack/openframe-frontend-core 0.0.216 → 0.0.217

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 (103) hide show
  1. package/dist/{chunk-SMCG2CCC.cjs → chunk-6DCKL73F.cjs} +24 -24
  2. package/dist/{chunk-SMCG2CCC.cjs.map → chunk-6DCKL73F.cjs.map} +1 -1
  3. package/dist/{chunk-QTKU6ULP.js → chunk-BVFRD34B.js} +2 -2
  4. package/dist/{chunk-CDLYRFDE.js → chunk-ENBGG2K2.js} +3767 -3610
  5. package/dist/chunk-ENBGG2K2.js.map +1 -0
  6. package/dist/{chunk-K4DFAVSO.cjs → chunk-G2HHSZ3S.cjs} +9 -9
  7. package/dist/{chunk-K4DFAVSO.cjs.map → chunk-G2HHSZ3S.cjs.map} +1 -1
  8. package/dist/{chunk-2V4SACHE.js → chunk-L6IBKPVM.js} +2 -2
  9. package/dist/{chunk-572WQWIX.cjs → chunk-MVQ3OODK.cjs} +9 -9
  10. package/dist/{chunk-572WQWIX.cjs.map → chunk-MVQ3OODK.cjs.map} +1 -1
  11. package/dist/{chunk-GVNQAGXB.js → chunk-N5IKPYRL.js} +3 -81
  12. package/dist/chunk-N5IKPYRL.js.map +1 -0
  13. package/dist/{chunk-VC3ND5RB.js → chunk-SWZUZYWR.js} +2 -2
  14. package/dist/{chunk-IH76P5R6.cjs → chunk-TYIBMDUZ.cjs} +8 -86
  15. package/dist/chunk-TYIBMDUZ.cjs.map +1 -0
  16. package/dist/{chunk-ZGTDUPTW.cjs → chunk-YWDC5BXM.cjs} +382 -225
  17. package/dist/chunk-YWDC5BXM.cjs.map +1 -0
  18. package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
  19. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  20. package/dist/components/chat/chat-input.d.ts.map +1 -1
  21. package/dist/components/chat/chat-message-row.d.ts +25 -0
  22. package/dist/components/chat/chat-message-row.d.ts.map +1 -0
  23. package/dist/components/chat/index.cjs +6 -2
  24. package/dist/components/chat/index.cjs.map +1 -1
  25. package/dist/components/chat/index.d.ts +1 -0
  26. package/dist/components/chat/index.d.ts.map +1 -1
  27. package/dist/components/chat/index.js +5 -1
  28. package/dist/components/chat/types/component.types.d.ts +8 -1
  29. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  30. package/dist/components/contact/index.cjs +3 -3
  31. package/dist/components/contact/index.js +2 -2
  32. package/dist/components/features/index.cjs +2 -2
  33. package/dist/components/features/index.js +1 -1
  34. package/dist/components/form.d.ts +1 -1
  35. package/dist/components/form.d.ts.map +1 -1
  36. package/dist/components/index.cjs +56 -52
  37. package/dist/components/index.cjs.map +1 -1
  38. package/dist/components/index.js +9 -5
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/navigation/index.cjs +2 -2
  41. package/dist/components/navigation/index.js +1 -1
  42. package/dist/components/onboarding-guides/index.cjs +18 -18
  43. package/dist/components/onboarding-guides/index.js +3 -3
  44. package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
  45. package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
  46. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
  47. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  48. package/dist/components/tickets/help-center-list.d.ts.map +1 -1
  49. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
  50. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  51. package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
  52. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  53. package/dist/components/tickets/index.cjs +294 -255
  54. package/dist/components/tickets/index.cjs.map +1 -1
  55. package/dist/components/tickets/index.d.ts +1 -0
  56. package/dist/components/tickets/index.d.ts.map +1 -1
  57. package/dist/components/tickets/index.js +360 -321
  58. package/dist/components/tickets/index.js.map +1 -1
  59. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  60. package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
  61. package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
  62. package/dist/components/tickets/types.d.ts +13 -0
  63. package/dist/components/tickets/types.d.ts.map +1 -1
  64. package/dist/components/ui/index.cjs +6 -2
  65. package/dist/components/ui/index.cjs.map +1 -1
  66. package/dist/components/ui/index.js +5 -1
  67. package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
  68. package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
  69. package/dist/index.cjs +6 -2
  70. package/dist/index.cjs.map +1 -1
  71. package/dist/index.js +5 -1
  72. package/dist/utils/index.cjs +59 -4
  73. package/dist/utils/index.cjs.map +1 -1
  74. package/dist/utils/index.js +59 -4
  75. package/dist/utils/index.js.map +1 -1
  76. package/dist/utils/scroll-into-view.d.ts +43 -48
  77. package/dist/utils/scroll-into-view.d.ts.map +1 -1
  78. package/package.json +1 -1
  79. package/src/components/chat/chat-attachment-bar.tsx +58 -22
  80. package/src/components/chat/chat-input.tsx +68 -29
  81. package/src/components/chat/chat-message-row.tsx +124 -0
  82. package/src/components/chat/index.ts +1 -0
  83. package/src/components/chat/types/component.types.ts +8 -1
  84. package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
  85. package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
  86. package/src/components/tickets/help-center-card.tsx +26 -29
  87. package/src/components/tickets/help-center-list.tsx +57 -10
  88. package/src/components/tickets/hooks/use-ticket-engagements.ts +17 -1
  89. package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
  90. package/src/components/tickets/index.ts +4 -0
  91. package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
  92. package/src/components/tickets/ticket-reply-composer.tsx +195 -0
  93. package/src/components/tickets/types.ts +14 -0
  94. package/src/components/ui/ticket-attachments-list.tsx +26 -8
  95. package/src/styles/app-globals.css +13 -0
  96. package/src/utils/scroll-into-view.ts +127 -53
  97. package/dist/chunk-CDLYRFDE.js.map +0 -1
  98. package/dist/chunk-GVNQAGXB.js.map +0 -1
  99. package/dist/chunk-IH76P5R6.cjs.map +0 -1
  100. package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
  101. /package/dist/{chunk-QTKU6ULP.js.map → chunk-BVFRD34B.js.map} +0 -0
  102. /package/dist/{chunk-2V4SACHE.js.map → chunk-L6IBKPVM.js.map} +0 -0
  103. /package/dist/{chunk-VC3ND5RB.js.map → chunk-SWZUZYWR.js.map} +0 -0
@@ -21,43 +21,32 @@
21
21
  * callbacks; we don't reach into the QueryClient.
22
22
  */
23
23
 
24
- import { useState } from 'react'
24
+ import { useStickToBottom } from 'use-stick-to-bottom'
25
25
  import { Button } from './../ui/button'
26
- import { Textarea } from './../ui/textarea'
27
- import {
28
- AlertDialog,
29
- AlertDialogAction,
30
- AlertDialogCancel,
31
- AlertDialogContent,
32
- AlertDialogDescription,
33
- AlertDialogFooter,
34
- AlertDialogHeader,
35
- AlertDialogTitle,
36
- } from './../ui/alert-dialog'
37
- import {
38
- ChatAttachmentAddButton,
39
- ChatAttachmentChipStrip,
40
- } from './../chat/chat-attachment-bar'
41
- import { useChatAttachments } from './../chat/hooks/use-chat-attachments'
42
26
  import { useChatIdentity } from './../chat/hooks/use-chat-identity'
27
+ import {
28
+ ChatMessageRow,
29
+ ChatMessageRowSkeleton,
30
+ } from './../chat/chat-message-row'
43
31
  import { EmptyState } from './../empty-state'
44
32
  import {
45
- ConversationCardRow,
46
- ConversationCardRowSkeletonList,
47
- } from './../shared/dev-section/dev-card-row'
48
- import type { TicketAttachment } from './../ui/ticket-attachments-list'
33
+ TicketAttachmentsList,
34
+ type TicketAttachment,
35
+ } from './../ui/ticket-attachments-list'
49
36
  import { SquareAvatar } from './../ui/square-avatar'
37
+ import { formatRelativeTime } from './../../utils/date-utils'
50
38
  import { useTicketEngagements } from './hooks/use-ticket-engagements'
51
39
  import type {
52
40
  TicketEngagementFile,
53
41
  } from './hooks/use-ticket-engagements'
54
42
  import { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card'
43
+ import { TicketReplyComposer } from './ticket-reply-composer'
55
44
  import type {
56
45
  AnyTicket,
57
46
  TicketAssignedOwner,
58
47
  MappedTicketActionError,
59
48
  } from './types'
60
- import { isOptimistic, TICKET_TEXT_MAX_CHARS } from './types'
49
+ import { isOptimistic, TICKET_LIVE_POLL_MS } from './types'
61
50
 
62
51
  /** Identity bundle threaded through the action callbacks: local mirror
63
52
  * UUID + HubSpot external_id. Actions send `external_id` to HubSpot
@@ -152,13 +141,12 @@ export function TicketDetailDrawer({
152
141
  onActionCollapsed={onActionCollapsed}
153
142
  />
154
143
  ) : (
155
- <OpenActions
144
+ <TicketReplyComposer
156
145
  ticket={ticket}
157
146
  busy={busy}
158
147
  supportSystemDown={supportSystemDown}
159
148
  onSendMessage={onSendMessage}
160
149
  onClose={onClose}
161
- onActionCollapsed={onActionCollapsed}
162
150
  />
163
151
  )}
164
152
  </div>
@@ -192,17 +180,83 @@ export function TicketDetailDrawer({
192
180
  // than any composed ticket body needs, so no real input is rejected.
193
181
  const TURN_SEPARATOR_RE = /[\s]{1,16}---[\s]{1,16}/g
194
182
 
183
+ // Slack-channel feed framing — ported from the hub's live community feed
184
+ // (`components/slack/chat-interface.tsx`: `:62` bounded card, `:83` padding +
185
+ // `overflow-y-auto`, `:85` `gap-4 md:gap-6` message column). Single source
186
+ // within the ticket feed; the delivery `DevCardRowSkeletonList` keeps its own
187
+ // (separate, untouched) frame literal. `max-h` is responsive (vs Slack's fixed
188
+ // height).
189
+ const TICKET_FEED_FRAME =
190
+ 'bg-ods-card border border-ods-border rounded-[6px] overflow-y-auto w-full'
191
+ // FIXED height for EVERY state (skeleton, content, empty) — the Slack feed uses
192
+ // a fixed-height box too (`chat-interface.tsx:62`). Fixed (not `max-h`) is the
193
+ // fix for the "open shows 1 message, then the container grows as engagements
194
+ // land" jank: the feed is its final size from first paint, so loaded content
195
+ // just fills/scrolls inside it — the box never resizes.
196
+ const TICKET_FEED_HEIGHT = 'h-[60vh] md:h-[420px]'
197
+ const TICKET_FEED_INNER = 'flex flex-col gap-4 md:gap-6 px-4 md:px-6 py-4 md:py-6'
198
+ // Enough skeleton rows to fill the fixed height (avatar 40px + header + 2 body
199
+ // lines + gap-6) so the loading state looks like a full conversation.
200
+ const TICKET_FEED_SKELETON_ROWS = 6
201
+
195
202
  function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
196
203
  const identity = useChatIdentity()
197
204
  // Optimistic placeholders don't have a real external_id yet — skip
198
205
  // the engagement fetch until the real ticket lands.
199
206
  const externalId = isOptimistic(ticket) ? null : ticket.external_id
200
- const { engagements, isLoading } = useTicketEngagements(externalId, !!externalId)
207
+ // Live conversation refresh: this panel only mounts while the drawer is
208
+ // open, so the constant interval is already gated to "open" (closing the
209
+ // drawer unmounts the panel → polling stops). New agent replies +
210
+ // attachments surface within one cadence without a manual refresh — the
211
+ // same 8s the list-level status/assignee poll uses (single source:
212
+ // TICKET_LIVE_POLL_MS). A background poll never flashes the skeleton
213
+ // (the `isLoading` guard below keys off "no data yet", not `isFetching`).
214
+ const { engagements, isLoading } = useTicketEngagements(
215
+ externalId,
216
+ !!externalId,
217
+ TICKET_LIVE_POLL_MS,
218
+ )
219
+
220
+ // Slack-style auto-tail (same lib mechanism `ChatMessageList` uses): jump to
221
+ // the newest message on open (`initial:'instant'`), smooth-scroll on a new
222
+ // reply. Called unconditionally here, BEFORE the empty/loading early-returns
223
+ // (Rules of Hooks); the refs attach ONLY to the content branch's scroll frame
224
+ // + column — never the cold-start skeleton (refs there would snap to skeleton
225
+ // height, then again to real content). This inner scroll is a SEPARATE
226
+ // container from `HelpCenterCard`'s page-level expand-scroll, so it never
227
+ // fights the "scroll to top of the ticket card" behavior.
228
+ const { scrollRef, contentRef } = useStickToBottom({ initial: 'instant', resize: 'smooth' })
201
229
 
202
230
  const bodyTurns = ticket.body
203
231
  ? ticket.body.split(TURN_SEPARATOR_RE).map((t) => t.trim()).filter(Boolean)
204
232
  : []
205
233
 
234
+ // Suppress `bodyTurns[0]` ("Original message") when the engagement
235
+ // timeline already has a customer-authored message whose body
236
+ // matches it. The channel-first create path in the hub writes the
237
+ // customer's message body BOTH into `hubspot_tickets.content` AND
238
+ // into the first `hubspot_ticket_conversation_messages` row — pre-
239
+ // 2026-05-29 the bot-intake-burst filter on the server dropped the
240
+ // first-customer message from engagements, so `bodyTurns[0]` was
241
+ // the only render. With channel-first, the engagement survives and
242
+ // both surfaces render the same text. Drop the redundant
243
+ // "Original message" turn when we detect that overlap.
244
+ //
245
+ // Only `bodyTurns[0]` is conditional. Subsequent turns ("Update N",
246
+ // "[Resolution]") come from `update_ticket.content_addendum` and
247
+ // are NEVER customer-written, so the engagement timeline can't
248
+ // match them. Leave their indices intact so `Update 1` still
249
+ // labels as such when `bodyTurns[0]` is suppressed.
250
+ const customerEngagementBodies = new Set<string>(
251
+ engagements
252
+ .filter((e) => e.authorRole === 'customer')
253
+ .map((e) => (e.body ?? '').trim())
254
+ .filter(Boolean),
255
+ )
256
+ const suppressBodyTurnZero =
257
+ bodyTurns.length > 0 &&
258
+ customerEngagementBodies.has(bodyTurns[0])
259
+
206
260
  // Customer name resolution precedence:
207
261
  // 1. LIVE chat identity (`identity.user.name`) — when the viewer
208
262
  // is the ticket's own customer. Always fresh.
@@ -229,12 +283,31 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
229
283
  ? identity.user?.avatarUrl ?? undefined
230
284
  : undefined
231
285
 
286
+ // Loading takes precedence over partial content — this is the fix for the
287
+ // "open shows 1 message, then the rest load and the box grows" jank. The
288
+ // ticket BODY is available synchronously, but the engagement timeline is
289
+ // fetched on open (caches off → EVERY open refetches). Rendering the body
290
+ // alone and then appending engagements as they arrive is the pop-in/grow the
291
+ // user hit. Instead: show the FULL-HEIGHT skeleton until the fetch settles,
292
+ // THEN render the whole conversation at once. Fixed height + skeleton-first =
293
+ // zero reflow and no partial render. `isLoading` (not `isFetching`) is true
294
+ // only on a cold open with no data yet — so a background refetch after sending
295
+ // a reply does NOT flash the skeleton; the new row just appends.
296
+ if (isLoading) {
297
+ // NO scroll refs here — they attach only to the real-content branch (refs on
298
+ // the skeleton would snap-to-bottom on skeleton height, then again on content).
299
+ return (
300
+ <div className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>
301
+ <div className={TICKET_FEED_INNER}>
302
+ {Array.from({ length: TICKET_FEED_SKELETON_ROWS }, (_, i) => (
303
+ <ChatMessageRowSkeleton key={i} />
304
+ ))}
305
+ </div>
306
+ </div>
307
+ )
308
+ }
309
+
232
310
  if (bodyTurns.length === 0 && engagements.length === 0) {
233
- // No content yet — distinguish loading from empty so the user
234
- // doesn't see "No conversation yet" flash during the initial fetch.
235
- if (isLoading) {
236
- return <ConversationCardRowSkeletonList rows={2} />
237
- }
238
311
  return (
239
312
  <EmptyState
240
313
  type="generic"
@@ -246,7 +319,8 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
246
319
  }
247
320
 
248
321
  return (
249
- <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
322
+ <div ref={scrollRef} className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>
323
+ <div ref={contentRef} className={TICKET_FEED_INNER}>
250
324
  {/* Customer-authored description + any legacy `---`-joined
251
325
  comments. Always rendered ABOVE the engagement timeline as
252
326
  "Original message" because the server's intake-burst filter
@@ -259,23 +333,22 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
259
333
  manually-entered description and engagements show subsequent
260
334
  replies — same flow, no duplication. */}
261
335
  {bodyTurns.map((turn, i) => {
336
+ // Drop the redundant first turn when the engagement timeline
337
+ // below already renders the same customer-authored body. See
338
+ // `suppressBodyTurnZero` derivation above for the rationale.
339
+ if (i === 0 && suppressBodyTurnZero) return null
262
340
  const isResolution = turn.startsWith('[Resolution]')
263
- const role =
264
- i === 0 ? 'Original message' : isResolution ? 'Resolution' : `Update ${i}`
265
341
  const text = isResolution ? turn.replace(/^\[Resolution\]\s*/, '') : turn
266
- // Body turns don't carry per-turn timestamps — `ticket.body` is
267
- // a single content field that HubSpot appends to. The role
268
- // label ("Original message" / "Update N" / "Resolution") plus
269
- // the Note engagements below it carry enough chronological
270
- // context that omitting a timestamp here keeps the row honest.
342
+ // Body turns don't carry per-turn timestamps — `ticket.body` is a
343
+ // single content field that HubSpot appends to. They render as the
344
+ // customer's own messages (no role chip the Slack-channel feed has
345
+ // no role labels), oldest-first above the engagement timeline.
271
346
  return (
272
- <ConversationCardRow
347
+ <ChatMessageRow
273
348
  key={`body-${i}-${turn.slice(0, 24)}`}
274
- author={customerName}
275
- role={role}
276
- avatarSrc={customerAvatar}
349
+ displayName={customerName}
350
+ avatarUrl={customerAvatar}
277
351
  body={text}
278
- variant="current-user"
279
352
  />
280
353
  )
281
354
  })}
@@ -366,27 +439,31 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
366
439
  // support bubbles was a legacy artifact from when Notes
367
440
  // were rendered and made customers think their support
368
441
  // engineer was leaving internal comments on their ticket.
442
+ const engAttachments = mapEngagementAttachments(eng.attachments)
369
443
  return (
370
- <ConversationCardRow
444
+ <ChatMessageRow
371
445
  key={eng.id}
372
- author={author}
373
- role="Reply"
374
- avatarSrc={avatarSrc}
375
- timestamp={eng.createdAt}
446
+ displayName={author}
447
+ avatarUrl={avatarSrc}
448
+ timeLabel={eng.createdAt ? formatRelativeTime(eng.createdAt) : null}
376
449
  body={stripAttachmentsPreamble(eng.body ?? '')}
377
- attachments={mapEngagementAttachments(eng.attachments)}
378
- variant={isCustomer ? 'current-user' : 'support'}
450
+ footer={
451
+ engAttachments.length > 0 ? (
452
+ <div className="mt-2">
453
+ <TicketAttachmentsList attachments={engAttachments} size="compact" />
454
+ </div>
455
+ ) : null
456
+ }
379
457
  />
380
458
  )
381
459
  })}
382
460
 
383
- {isLoading && (
384
- // Trailing single-row skeleton when the panel already has
385
- // content rendered drawer is showing the customer's body
386
- // turns + cached engagements while a background refetch is
387
- // in flight. Single row keeps the placeholder modest.
388
- <ConversationCardRowSkeletonList rows={1} />
389
- )}
461
+ {/* No trailing refetch skeleton in the tailing feed: a skeleton mounted
462
+ inside `contentRef` on a background refetch would make the auto-tail
463
+ smooth-scroll to the skeleton and then again to the real row (a
464
+ double-jump). The smooth-tail to the appended real reply IS the
465
+ feedback. (Removed the former background-refetch rows={1} skeleton.) */}
466
+ </div>
390
467
  </div>
391
468
  )
392
469
  }
@@ -403,6 +480,11 @@ function mapEngagementAttachments(
403
480
  id: f.id,
404
481
  fileName: f.name ?? `file-${f.id}`,
405
482
  fileSize: f.size ? formatBytes(f.size) : '',
483
+ // Show an inline thumbnail for image attachments (the signed `url` is a
484
+ // viewable URL). Non-images fall back to the file-type icon. SquareAvatar
485
+ // degrades to initials on a broken/expired image URL.
486
+ thumbnailSrc:
487
+ f.url && (f.mime?.startsWith('image/') ?? false) ? f.url : undefined,
406
488
  onDownload: f.url
407
489
  ? () => window.open(f.url!, '_blank', 'noopener,noreferrer')
408
490
  : undefined,
@@ -456,8 +538,13 @@ function ReopenAction({
456
538
  }
457
539
  return (
458
540
  <div className="flex justify-end">
541
+ {/* Reopen is a secondary, reversible action — `outline` (not the filled
542
+ accent primary) so it reads as available without dominating the
543
+ closed-ticket view. */}
459
544
  <Button
460
545
  type="button"
546
+ variant="outline"
547
+ size="small"
461
548
  onClick={() => void handleReopen()}
462
549
  disabled={busy || supportSystemDown}
463
550
  loading={busy}
@@ -468,149 +555,6 @@ function ReopenAction({
468
555
  )
469
556
  }
470
557
 
471
- function OpenActions({
472
- ticket,
473
- busy,
474
- supportSystemDown,
475
- onSendMessage,
476
- onClose,
477
- onActionCollapsed,
478
- }: {
479
- ticket: AnyTicket
480
- busy: boolean
481
- supportSystemDown: boolean
482
- onSendMessage: TicketDetailDrawerProps['onSendMessage']
483
- onClose: TicketDetailDrawerProps['onClose']
484
- onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']
485
- }) {
486
- const [messageText, setMessageText] = useState('')
487
- const [resolution, setResolution] = useState('')
488
- const [closeDialogOpen, setCloseDialogOpen] = useState(false)
489
-
490
- const attachments = useChatAttachments()
491
-
492
- const disabled = busy || supportSystemDown
493
- const ticketRef: TicketRef = { id: ticket.id, external_id: ticket.external_id }
494
-
495
- const hasText = messageText.trim().length > 0
496
- const hasReadyFiles = attachments.readyAttachments.length > 0
497
- const canSend =
498
- !disabled && (hasText || hasReadyFiles) && !attachments.hasInflightUploads
499
-
500
- const sendMessage = async () => {
501
- if (!canSend) return
502
- const ok = await onSendMessage(ticketRef, messageText.trim(), attachments.readyAttachments)
503
- if (ok) {
504
- setMessageText('')
505
- attachments.clear()
506
- }
507
- }
508
-
509
- const confirmClose = async () => {
510
- setCloseDialogOpen(false)
511
- await onClose(ticketRef, resolution.trim() || undefined)
512
- setResolution('')
513
- // Intentionally do NOT call `onActionCollapsed()` here. See the
514
- // matching comment in ReopenActions.handleReopen above — collapsing
515
- // the drawer after a successful close now dismisses the ticket the
516
- // user is actively working on, which is the exact UX bug PR #1053
517
- // set out to fix. The optimistic in-place status update keeps the
518
- // row mounted with the new badge; that's the only feedback needed.
519
- }
520
-
521
- return (
522
- <div className="flex flex-col gap-3">
523
- <div className="flex flex-col gap-2">
524
- <Textarea
525
- value={messageText}
526
- onChange={(e) => setMessageText(e.target.value)}
527
- placeholder="Type a reply… (attach files if needed)"
528
- disabled={disabled}
529
- rows={3}
530
- maxLength={TICKET_TEXT_MAX_CHARS}
531
- />
532
- <ChatAttachmentChipStrip
533
- attachments={attachments.attachments}
534
- onRemove={attachments.removeAttachment}
535
- disabled={disabled}
536
- />
537
- <div className="flex justify-between items-center gap-2 flex-wrap">
538
- <div className="flex items-center gap-2">
539
- <ChatAttachmentAddButton
540
- attachmentsEnabled={!supportSystemDown}
541
- attachmentsCount={attachments.attachments.length}
542
- onAddFiles={attachments.addFiles}
543
- disabled={disabled}
544
- />
545
- <span className="text-xs text-ods-text-secondary">Attach files</span>
546
- </div>
547
- <div className="flex items-center gap-2">
548
- <Button
549
- type="button"
550
- variant="transparent"
551
- onClick={() => setCloseDialogOpen(true)}
552
- disabled={disabled}
553
- className="bg-ods-error hover:bg-ods-error-hover text-white border-transparent"
554
- >
555
- Close ticket
556
- </Button>
557
- <Button
558
- type="button"
559
- onClick={() => void sendMessage()}
560
- disabled={!canSend}
561
- loading={busy}
562
- >
563
- Send reply
564
- </Button>
565
- </div>
566
- </div>
567
- </div>
568
-
569
- {/* Destructive-confirm — canonical pattern from
570
- `components/admin/doc-orchestrator-dashboard.tsx:471`.
571
- AlertDialog (NOT ModalV2) is the lib's standard for
572
- destructive confirmations; bg-ods-error is the canonical
573
- destructive button color. */}
574
- <AlertDialog open={closeDialogOpen} onOpenChange={setCloseDialogOpen}>
575
- <AlertDialogContent className="bg-ods-card border-ods-border">
576
- <AlertDialogHeader>
577
- <AlertDialogTitle className="text-ods-text-primary font-['DM_Sans'] text-[20px] font-semibold">
578
- Close this ticket?
579
- </AlertDialogTitle>
580
- <AlertDialogDescription className="text-ods-text-secondary font-['DM_Sans'] text-[14px]">
581
- Add an optional resolution note below. You can reopen the ticket
582
- later if needed.
583
- </AlertDialogDescription>
584
- </AlertDialogHeader>
585
- <Textarea
586
- value={resolution}
587
- onChange={(e) => setResolution(e.target.value)}
588
- placeholder="Resolution (optional)"
589
- rows={3}
590
- maxLength={TICKET_TEXT_MAX_CHARS}
591
- className="mt-2"
592
- />
593
- <AlertDialogFooter>
594
- <AlertDialogCancel
595
- disabled={busy}
596
- className="bg-transparent border-ods-border text-ods-text-primary hover:bg-ods-border"
597
- >
598
- Cancel
599
- </AlertDialogCancel>
600
- <AlertDialogAction
601
- onClick={() => void confirmClose()}
602
- disabled={busy}
603
- className="bg-ods-error hover:bg-ods-error-hover text-white"
604
- >
605
- Close ticket
606
- </AlertDialogAction>
607
- </AlertDialogFooter>
608
- </AlertDialogContent>
609
- </AlertDialog>
610
- </div>
611
- )
612
- }
613
-
614
558
  /**
615
559
  * Persistent banner above the drawer composer/actions when the most
616
560
  * recent customer reply failed with a reply-specific code (HUBSPOT_5XX
@@ -0,0 +1,195 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react'
4
+ import { Button } from './../ui/button'
5
+ import { Textarea } from './../ui/textarea'
6
+ import {
7
+ AlertDialog,
8
+ AlertDialogAction,
9
+ AlertDialogCancel,
10
+ AlertDialogContent,
11
+ AlertDialogDescription,
12
+ AlertDialogFooter,
13
+ AlertDialogHeader,
14
+ AlertDialogTitle,
15
+ } from './../ui/alert-dialog'
16
+ import { ChatInput } from './../chat/chat-input'
17
+ import {
18
+ ChatAttachmentAddButton,
19
+ ChatAttachmentChipStrip,
20
+ } from './../chat/chat-attachment-bar'
21
+ import { useChatAttachments } from './../chat/hooks/use-chat-attachments'
22
+ import type { AnyTicket } from './types'
23
+ import { TICKET_TEXT_MAX_CHARS } from './types'
24
+ import type { TicketDetailDrawerProps, TicketRef } from './ticket-detail-drawer'
25
+
26
+ export interface TicketReplyComposerProps {
27
+ ticket: AnyTicket
28
+ busy: boolean
29
+ supportSystemDown: boolean
30
+ onSendMessage: TicketDetailDrawerProps['onSendMessage']
31
+ onClose: TicketDetailDrawerProps['onClose']
32
+ }
33
+
34
+ /**
35
+ * Open-ticket reply composer — REUSES the exact same layout as the global
36
+ * Ask-AI chat composer (`embeddable-chat.tsx`): the shared `<ChatInput>` with
37
+ * the staged-file chip strip above it and the attachment `+` button in a
38
+ * controls row BELOW the input (identical placement to the global chat), plus
39
+ * the destructive close-confirm `AlertDialog`.
40
+ *
41
+ * Replaces the former `OpenActions` raw-`<Textarea>` composer. The text lives
42
+ * inside `ChatInput`; this component owns only the attachment bag + the close
43
+ * dialog. Send semantics:
44
+ * - `sending={busy || hasInflightUploads}` disables the input while sending
45
+ * OR uploading (same as the global chat); `allowEmptySend` lets an
46
+ * attachments-only reply send once uploads finish;
47
+ * - the typed draft + staged files survive a FAILED send: `handleSend`
48
+ * returns `false`, so `ChatInput` keeps the text and we only `clear()` the
49
+ * attachments on success;
50
+ * - close does NOT collapse the drawer (no `onActionCollapsed`).
51
+ *
52
+ * `disabled={supportSystemDown}` is the only flag that drives the "Connection
53
+ * lost…" placeholder. The `+` attach gate stays `!supportSystemDown` (same gate
54
+ * the old composer used).
55
+ */
56
+ export function TicketReplyComposer({
57
+ ticket,
58
+ busy,
59
+ supportSystemDown,
60
+ onSendMessage,
61
+ onClose,
62
+ }: TicketReplyComposerProps) {
63
+ const [resolution, setResolution] = useState('')
64
+ const [closeDialogOpen, setCloseDialogOpen] = useState(false)
65
+ const attachments = useChatAttachments()
66
+
67
+ const ticketRef: TicketRef = { id: ticket.id, external_id: ticket.external_id }
68
+ const hasReadyFiles = attachments.readyAttachments.length > 0
69
+
70
+ const handleSend = useCallback(
71
+ async (text: string): Promise<boolean> => {
72
+ const ref: TicketRef = { id: ticket.id, external_id: ticket.external_id }
73
+ const ok = await onSendMessage(ref, text.trim(), attachments.readyAttachments)
74
+ if (ok) attachments.clear()
75
+ return ok
76
+ },
77
+ // Depend on the reactive projections, not the whole bag (a fresh object each
78
+ // render). `readyAttachments` is memo-stable; `clear` is callback-stable.
79
+ [
80
+ onSendMessage,
81
+ ticket.id,
82
+ ticket.external_id,
83
+ attachments.readyAttachments,
84
+ attachments.clear,
85
+ ],
86
+ )
87
+
88
+ const confirmClose = async () => {
89
+ setCloseDialogOpen(false)
90
+ await onClose(ticketRef, resolution.trim() || undefined)
91
+ setResolution('')
92
+ // Intentionally NO `onActionCollapsed()` — collapsing the drawer after a
93
+ // close dismisses the ticket the user is working on (PR #1053). The
94
+ // optimistic in-place status update keeps the row mounted with the new
95
+ // badge; that is the only feedback needed.
96
+ }
97
+
98
+ const disabled = busy || supportSystemDown
99
+
100
+ return (
101
+ <div className="flex flex-col gap-2">
102
+ {/* Unified composer — mirrors the global Ask-AI chat layout
103
+ (embeddable-chat.tsx :1160-1206): compact staged-file chip strip
104
+ above, the shared <ChatInput> (Send icon = the PRIMARY action), then a
105
+ quiet bottom toolbar: attachment "+" on the left, and a LOW-EMPHASIS
106
+ "Close ticket" text button on the right. Closing is reversible (Reopen
107
+ exists), so it is NOT styled as destructive/danger — that would
108
+ over-signal a routine, undoable status change (UX best practice:
109
+ reserve red for irreversible actions). */}
110
+ <ChatAttachmentChipStrip
111
+ attachments={attachments.attachments}
112
+ onRemove={attachments.removeAttachment}
113
+ disabled={disabled}
114
+ size="compact"
115
+ />
116
+ <ChatInput
117
+ fullWidth
118
+ // Focus the reply box when the drawer opens so the customer can type
119
+ // immediately. `ChatInput`'s autoFocus uses `{ preventScroll: true }`,
120
+ // so this does NOT scroll the page — the card's smooth scroll-to-top
121
+ // (HelpCenterCard) wins, and the input stays focused + visible (it
122
+ // sits within the viewport below the fixed-height feed).
123
+ autoFocus
124
+ placeholder="Type a reply…"
125
+ sending={busy || attachments.hasInflightUploads}
126
+ disabled={supportSystemDown}
127
+ allowEmptySend={hasReadyFiles}
128
+ maxLength={TICKET_TEXT_MAX_CHARS}
129
+ onSend={handleSend}
130
+ />
131
+ <div className="flex items-center gap-2 w-full">
132
+ {!supportSystemDown && (
133
+ <ChatAttachmentAddButton
134
+ attachmentsEnabled
135
+ attachmentsCount={attachments.attachments.length}
136
+ onAddFiles={attachments.addFiles}
137
+ disabled={disabled}
138
+ size="compact"
139
+ />
140
+ )}
141
+ <div className="flex-1 min-w-0" />
142
+ <Button
143
+ type="button"
144
+ variant="transparent"
145
+ size="small"
146
+ onClick={() => setCloseDialogOpen(true)}
147
+ disabled={disabled}
148
+ className="text-ods-text-secondary hover:text-ods-text-primary"
149
+ >
150
+ Close ticket
151
+ </Button>
152
+ </div>
153
+
154
+ {/* Confirm dialog — collects an optional resolution note. Closing is
155
+ REVERSIBLE, so the confirm action is the standard accent primary, NOT
156
+ a red destructive button. */}
157
+ <AlertDialog open={closeDialogOpen} onOpenChange={setCloseDialogOpen}>
158
+ <AlertDialogContent className="bg-ods-card border-ods-border">
159
+ <AlertDialogHeader>
160
+ <AlertDialogTitle className="text-ods-text-primary">
161
+ Close this ticket?
162
+ </AlertDialogTitle>
163
+ <AlertDialogDescription className="text-ods-text-secondary">
164
+ Add an optional resolution note below. You can reopen the ticket
165
+ later if needed.
166
+ </AlertDialogDescription>
167
+ </AlertDialogHeader>
168
+ <Textarea
169
+ value={resolution}
170
+ onChange={(e) => setResolution(e.target.value)}
171
+ placeholder="Resolution (optional)"
172
+ rows={3}
173
+ maxLength={TICKET_TEXT_MAX_CHARS}
174
+ className="mt-2"
175
+ />
176
+ <AlertDialogFooter>
177
+ <AlertDialogCancel
178
+ disabled={busy}
179
+ className="bg-transparent border-ods-border text-ods-text-primary hover:bg-ods-border"
180
+ >
181
+ Cancel
182
+ </AlertDialogCancel>
183
+ <AlertDialogAction
184
+ onClick={() => void confirmClose()}
185
+ disabled={busy}
186
+ className="bg-ods-accent text-ods-text-on-accent hover:bg-ods-accent-hover"
187
+ >
188
+ Close ticket
189
+ </AlertDialogAction>
190
+ </AlertDialogFooter>
191
+ </AlertDialogContent>
192
+ </AlertDialog>
193
+ </div>
194
+ )
195
+ }
@@ -211,6 +211,20 @@ export interface MappedTicketActionError {
211
211
  */
212
212
  export const TICKET_TEXT_MAX_CHARS = 5000
213
213
 
214
+ /**
215
+ * Live-refresh cadence (ms) for an OPEN ticket drawer. Drives BOTH
216
+ * surfaces that must stay current while the customer is looking at a
217
+ * ticket:
218
+ * - the ticket LIST poll (`useTicketsList.refetchInterval`) → surfaces
219
+ * out-of-band status / pipeline / priority / assignee changes;
220
+ * - the CONVERSATION poll (`useTicketEngagements.refetchInterval`) →
221
+ * surfaces new agent replies + attachments.
222
+ * Single source of truth so the two surfaces never drift. Both leave
223
+ * `refetchIntervalInBackground` at its default (false), so polling pauses
224
+ * on a hidden tab — no wasted requests when the user tabs away.
225
+ */
226
+ export const TICKET_LIVE_POLL_MS = 8000
227
+
214
228
  /**
215
229
  * Centralized toast copy. Keep all wording here so QA / localization
216
230
  * can find every user-visible string in one file.