@flamingo-stack/openframe-frontend-core 0.0.216 → 0.0.217-snapshot.20260601151546

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 (144) hide show
  1. package/dist/{chunk-2V4SACHE.js → chunk-EKBM4FHK.js} +2 -2
  2. package/dist/{chunk-VC3ND5RB.js → chunk-EWA2NFUR.js} +2 -2
  3. package/dist/{chunk-IH76P5R6.cjs → chunk-FZZBCRID.cjs} +8 -86
  4. package/dist/chunk-FZZBCRID.cjs.map +1 -0
  5. package/dist/{chunk-K4DFAVSO.cjs → chunk-GE64T3JT.cjs} +9 -9
  6. package/dist/{chunk-K4DFAVSO.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
  7. package/dist/{chunk-ZGTDUPTW.cjs → chunk-L5RSJE2I.cjs} +2318 -1136
  8. package/dist/chunk-L5RSJE2I.cjs.map +1 -0
  9. package/dist/{chunk-QTKU6ULP.js → chunk-OHOUSDAY.js} +2 -2
  10. package/dist/{chunk-572WQWIX.cjs → chunk-S4SVD5JI.cjs} +9 -9
  11. package/dist/{chunk-572WQWIX.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
  12. package/dist/{chunk-GVNQAGXB.js → chunk-SWIR5EB2.js} +3 -81
  13. package/dist/chunk-SWIR5EB2.js.map +1 -0
  14. package/dist/{chunk-SMCG2CCC.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
  15. package/dist/{chunk-SMCG2CCC.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
  16. package/dist/{chunk-CDLYRFDE.js → chunk-V5JY5RSY.js} +5710 -4528
  17. package/dist/chunk-V5JY5RSY.js.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/embeddable-chat.d.ts +13 -0
  24. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  25. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
  26. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  27. package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
  28. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  29. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  30. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  31. package/dist/components/chat/index.cjs +6 -2
  32. package/dist/components/chat/index.cjs.map +1 -1
  33. package/dist/components/chat/index.d.ts +1 -0
  34. package/dist/components/chat/index.d.ts.map +1 -1
  35. package/dist/components/chat/index.js +5 -1
  36. package/dist/components/chat/types/component.types.d.ts +8 -1
  37. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  38. package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
  39. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  40. package/dist/components/contact/index.cjs +3 -3
  41. package/dist/components/contact/index.js +2 -2
  42. package/dist/components/features/index.cjs +2 -2
  43. package/dist/components/features/index.js +1 -1
  44. package/dist/components/form.d.ts +1 -1
  45. package/dist/components/form.d.ts.map +1 -1
  46. package/dist/components/index.cjs +78 -52
  47. package/dist/components/index.cjs.map +1 -1
  48. package/dist/components/index.js +31 -5
  49. package/dist/components/index.js.map +1 -1
  50. package/dist/components/navigation/app-header.d.ts +7 -0
  51. package/dist/components/navigation/app-header.d.ts.map +1 -1
  52. package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
  53. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
  54. package/dist/components/navigation/app-layout.d.ts +9 -1
  55. package/dist/components/navigation/app-layout.d.ts.map +1 -1
  56. package/dist/components/navigation/header-mingo-button.d.ts +21 -0
  57. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
  58. package/dist/components/navigation/index.cjs +24 -2
  59. package/dist/components/navigation/index.cjs.map +1 -1
  60. package/dist/components/navigation/index.d.ts +5 -1
  61. package/dist/components/navigation/index.d.ts.map +1 -1
  62. package/dist/components/navigation/index.js +23 -1
  63. package/dist/components/onboarding-guides/index.cjs +18 -18
  64. package/dist/components/onboarding-guides/index.js +3 -3
  65. package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
  66. package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
  67. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
  68. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  69. package/dist/components/tickets/help-center-list.d.ts.map +1 -1
  70. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
  71. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  72. package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
  73. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  74. package/dist/components/tickets/index.cjs +309 -256
  75. package/dist/components/tickets/index.cjs.map +1 -1
  76. package/dist/components/tickets/index.d.ts +1 -0
  77. package/dist/components/tickets/index.d.ts.map +1 -1
  78. package/dist/components/tickets/index.js +376 -323
  79. package/dist/components/tickets/index.js.map +1 -1
  80. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  81. package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
  82. package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
  83. package/dist/components/tickets/types.d.ts +13 -0
  84. package/dist/components/tickets/types.d.ts.map +1 -1
  85. package/dist/components/ui/index.cjs +6 -2
  86. package/dist/components/ui/index.cjs.map +1 -1
  87. package/dist/components/ui/index.js +5 -1
  88. package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
  89. package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
  90. package/dist/index.cjs +30 -2
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.js +29 -1
  93. package/dist/utils/embed-authed-fetch.d.ts +80 -0
  94. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  95. package/dist/utils/index.cjs +129 -9
  96. package/dist/utils/index.cjs.map +1 -1
  97. package/dist/utils/index.d.ts +1 -1
  98. package/dist/utils/index.d.ts.map +1 -1
  99. package/dist/utils/index.js +129 -10
  100. package/dist/utils/index.js.map +1 -1
  101. package/dist/utils/scroll-into-view.d.ts +43 -48
  102. package/dist/utils/scroll-into-view.d.ts.map +1 -1
  103. package/package.json +2 -2
  104. package/src/components/chat/chat-attachment-bar.tsx +58 -22
  105. package/src/components/chat/chat-input.tsx +68 -29
  106. package/src/components/chat/chat-message-row.tsx +124 -0
  107. package/src/components/chat/embeddable-chat.tsx +154 -37
  108. package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
  109. package/src/components/chat/hooks/use-slash-commands.ts +10 -1
  110. package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
  111. package/src/components/chat/hooks/use-unified-chat.ts +59 -0
  112. package/src/components/chat/index.ts +1 -0
  113. package/src/components/chat/types/component.types.ts +8 -1
  114. package/src/components/chat/types/unified-chat-state.types.ts +116 -0
  115. package/src/components/navigation/app-header.tsx +23 -0
  116. package/src/components/navigation/app-layout-drawer.tsx +620 -0
  117. package/src/components/navigation/app-layout.tsx +65 -26
  118. package/src/components/navigation/header-mingo-button.tsx +58 -0
  119. package/src/components/navigation/index.ts +17 -1
  120. package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
  121. package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
  122. package/src/components/tickets/help-center-card.tsx +26 -29
  123. package/src/components/tickets/help-center-list.tsx +57 -10
  124. package/src/components/tickets/hooks/use-ticket-engagements.ts +41 -5
  125. package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
  126. package/src/components/tickets/index.ts +4 -0
  127. package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
  128. package/src/components/tickets/ticket-reply-composer.tsx +195 -0
  129. package/src/components/tickets/types.ts +14 -0
  130. package/src/components/ui/ticket-attachments-list.tsx +26 -8
  131. package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
  132. package/src/styles/app-globals.css +13 -0
  133. package/src/utils/.embed-authed-fetch.md +7 -0
  134. package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
  135. package/src/utils/embed-authed-fetch.ts +247 -7
  136. package/src/utils/index.ts +5 -1
  137. package/src/utils/scroll-into-view.ts +127 -53
  138. package/dist/chunk-CDLYRFDE.js.map +0 -1
  139. package/dist/chunk-GVNQAGXB.js.map +0 -1
  140. package/dist/chunk-IH76P5R6.cjs.map +0 -1
  141. package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
  142. /package/dist/{chunk-2V4SACHE.js.map → chunk-EKBM4FHK.js.map} +0 -0
  143. /package/dist/{chunk-VC3ND5RB.js.map → chunk-EWA2NFUR.js.map} +0 -0
  144. /package/dist/{chunk-QTKU6ULP.js.map → chunk-OHOUSDAY.js.map} +0 -0
@@ -73,17 +73,33 @@ export interface UseTicketEngagementsReturn {
73
73
  refetch: () => void
74
74
  }
75
75
 
76
- export function useTicketEngagements(externalTicketId: string | null | undefined, enabled = true): UseTicketEngagementsReturn {
76
+ export function useTicketEngagements(
77
+ externalTicketId: string | null | undefined,
78
+ enabled = true,
79
+ /** Poll cadence (ms) for live conversation refresh while the drawer is
80
+ * open. The drawer only mounts this hook when expanded, so a constant
81
+ * here is already gated to "drawer open" — closing the drawer unmounts
82
+ * the panel and the polling stops. `false`/undefined disables it (the
83
+ * default, preserving prior fetch-once-per-open behavior for any other
84
+ * caller). Mirrors `useTicketsList.refetchInterval`; see
85
+ * `TICKET_LIVE_POLL_MS`. */
86
+ refetchInterval: number | false = false,
87
+ ): UseTicketEngagementsReturn {
77
88
  const identity = useChatIdentity()
78
89
  const identityKey = identity.user?.email ?? 'anon'
79
90
 
80
- const queryEnabled =
91
+ // "Will this ticket fetch its timeline once identity is ready?" — i.e. it's a
92
+ // real, non-optimistic ticket the caller enabled. INDEPENDENT of whether
93
+ // identity has resolved yet, so the loading state is correct from the very
94
+ // first render (before `useChatIdentity` settles).
95
+ const fetchable =
81
96
  enabled &&
82
- identity.authTier !== 'anon' &&
83
- !!identity.user?.email &&
84
97
  !!externalTicketId &&
85
98
  !externalTicketId.startsWith('temp-') // optimistic placeholders have no real id yet
86
99
 
100
+ const queryEnabled =
101
+ fetchable && identity.authTier !== 'anon' && !!identity.user?.email
102
+
87
103
  const query = useQuery({
88
104
  queryKey: ['ticket-engagements', externalTicketId, identityKey],
89
105
  enabled: queryEnabled,
@@ -94,6 +110,11 @@ export function useTicketEngagements(externalTicketId: string | null | undefined
94
110
  gcTime: 0,
95
111
  refetchOnMount: 'always',
96
112
  refetchOnWindowFocus: true,
113
+ // Live conversation: poll while the caller opts in (drawer open). New
114
+ // agent replies + attachments appear within one interval without a
115
+ // manual refresh. `refetchIntervalInBackground` stays false (default)
116
+ // so polling pauses on a hidden tab.
117
+ refetchInterval,
97
118
  queryFn: async (): Promise<TicketEngagement[]> => {
98
119
  const response = await embedAuthedFetch(LIST_ENGAGEMENTS_ENDPOINT, {
99
120
  method: 'POST',
@@ -110,7 +131,22 @@ export function useTicketEngagements(externalTicketId: string | null | undefined
110
131
 
111
132
  return {
112
133
  engagements: query.data ?? [],
113
- isLoading: queryEnabled && query.isLoading,
134
+ // Loading-state truth that prevents the "body → blink → skeleton → data"
135
+ // double-flash. The bug: `useChatIdentity` starts at anon defaults and
136
+ // resolves async, so on the first render `queryEnabled` is false and the
137
+ // OLD `queryEnabled && query.isLoading` returned FALSE — the panel rendered
138
+ // the ticket body, THEN identity resolved, the query enabled, isLoading
139
+ // flipped true → skeleton appeared (the blink), then data landed.
140
+ //
141
+ // Fix: for a fetchable ticket we are "loading" whenever we don't yet have
142
+ // the timeline to show — that includes the window while identity is still
143
+ // resolving (so we skeleton from the FIRST render, never the body) AND the
144
+ // cold query fetch (`data === undefined`). A background poll keeps
145
+ // `query.data` defined, so it never re-flashes the skeleton. Non-fetchable
146
+ // (optimistic/disabled) or a resolved-anon viewer → not loading.
147
+ isLoading:
148
+ fetchable &&
149
+ (identity.isLoading || (queryEnabled && query.data === undefined)),
114
150
  isFetching: query.isFetching,
115
151
  error: (query.error as Error | null) ?? null,
116
152
  refetch: () => {
@@ -58,6 +58,13 @@ export interface UseTicketsListFilters {
58
58
  page?: number
59
59
  /** Items per page (server caps at 100). Defaults to 20. */
60
60
  pageSize?: number
61
+ /** Poll interval (ms) for live updates — e.g. so a ticket CLOSED out-of-band
62
+ * on HubSpot flips the status badge + open/reopen affordance without a manual
63
+ * refresh. `false`/0/undefined disables polling. The hub passes a value only
64
+ * while a drawer is open (mirror webhooks keep the server fresh; this surfaces
65
+ * it client-side). TanStack pauses interval polling while the tab is hidden,
66
+ * so there are no wasted background requests. */
67
+ refetchInterval?: number | false
61
68
  }
62
69
 
63
70
  export interface UseTicketsListReturn {
@@ -103,6 +110,8 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
103
110
  // previous identity's data.
104
111
  const identityKey = customerEmail || 'anon'
105
112
 
113
+ const refetchInterval = filters.refetchInterval ?? false
114
+
106
115
  const query = useQuery({
107
116
  queryKey: ['tickets', 'self', identityKey, search, statusFilter, page, pageSize],
108
117
  enabled,
@@ -115,6 +124,10 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
115
124
  gcTime: 0,
116
125
  refetchOnMount: 'always',
117
126
  refetchOnWindowFocus: true,
127
+ // Live status: poll while the caller opts in (drawer open). Defaults to
128
+ // false. `refetchIntervalInBackground` stays false (the default) so polling
129
+ // pauses on a hidden tab — no wasted requests when the user tabs away.
130
+ refetchInterval,
118
131
  queryFn: async (): Promise<FindTicketResponse> => {
119
132
  const body: Record<string, string | number> = {
120
133
  query: search,
@@ -10,6 +10,10 @@ export {
10
10
  TicketLinkedDeliveryCard,
11
11
  type TicketLinkedDeliveryCardProps,
12
12
  } from './ticket-linked-delivery-card'
13
+ export {
14
+ TicketReplyComposer,
15
+ type TicketReplyComposerProps,
16
+ } from './ticket-reply-composer'
13
17
  // Help Center — full-page customer-facing surface used by
14
18
  // openframe's `/tickets` route. Composes `DevSectionPage` chrome
15
19
  // (hero + search + filter) + a creation form above the controls +
@@ -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