@flamingo-stack/openframe-frontend-core 0.0.216 → 0.0.217-snapshot.20260601003634
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-SMCG2CCC.cjs → chunk-6DCKL73F.cjs} +24 -24
- package/dist/{chunk-SMCG2CCC.cjs.map → chunk-6DCKL73F.cjs.map} +1 -1
- package/dist/{chunk-QTKU6ULP.js → chunk-BVFRD34B.js} +2 -2
- package/dist/{chunk-CDLYRFDE.js → chunk-ENBGG2K2.js} +3767 -3610
- package/dist/chunk-ENBGG2K2.js.map +1 -0
- package/dist/{chunk-K4DFAVSO.cjs → chunk-G2HHSZ3S.cjs} +9 -9
- package/dist/{chunk-K4DFAVSO.cjs.map → chunk-G2HHSZ3S.cjs.map} +1 -1
- package/dist/{chunk-2V4SACHE.js → chunk-L6IBKPVM.js} +2 -2
- package/dist/{chunk-572WQWIX.cjs → chunk-MVQ3OODK.cjs} +9 -9
- package/dist/{chunk-572WQWIX.cjs.map → chunk-MVQ3OODK.cjs.map} +1 -1
- package/dist/{chunk-GVNQAGXB.js → chunk-N5IKPYRL.js} +3 -81
- package/dist/chunk-N5IKPYRL.js.map +1 -0
- package/dist/{chunk-VC3ND5RB.js → chunk-SWZUZYWR.js} +2 -2
- package/dist/{chunk-IH76P5R6.cjs → chunk-TYIBMDUZ.cjs} +8 -86
- package/dist/chunk-TYIBMDUZ.cjs.map +1 -0
- package/dist/{chunk-ZGTDUPTW.cjs → chunk-YWDC5BXM.cjs} +382 -225
- package/dist/chunk-YWDC5BXM.cjs.map +1 -0
- package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
- package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/chat-message-row.d.ts +25 -0
- package/dist/components/chat/chat-message-row.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +6 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +5 -1
- package/dist/components/chat/types/component.types.d.ts +8 -1
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/form.d.ts +1 -1
- package/dist/components/form.d.ts.map +1 -1
- package/dist/components/index.cjs +56 -52
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +9 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +18 -18
- package/dist/components/onboarding-guides/index.js +3 -3
- package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
- package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
- package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/help-center-list.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
- package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +309 -256
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.d.ts +1 -0
- package/dist/components/tickets/index.d.ts.map +1 -1
- package/dist/components/tickets/index.js +376 -323
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
- package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
- package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
- package/dist/components/tickets/types.d.ts +13 -0
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +6 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
- package/dist/index.cjs +6 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -1
- package/dist/utils/index.cjs +59 -4
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +59 -4
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/scroll-into-view.d.ts +43 -48
- package/dist/utils/scroll-into-view.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-attachment-bar.tsx +58 -22
- package/src/components/chat/chat-input.tsx +68 -29
- package/src/components/chat/chat-message-row.tsx +124 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/types/component.types.ts +8 -1
- package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
- package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
- package/src/components/tickets/help-center-card.tsx +26 -29
- package/src/components/tickets/help-center-list.tsx +57 -10
- package/src/components/tickets/hooks/use-ticket-engagements.ts +41 -5
- package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
- package/src/components/tickets/index.ts +4 -0
- package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
- package/src/components/tickets/ticket-reply-composer.tsx +195 -0
- package/src/components/tickets/types.ts +14 -0
- package/src/components/ui/ticket-attachments-list.tsx +26 -8
- package/src/styles/app-globals.css +13 -0
- package/src/utils/scroll-into-view.ts +127 -53
- package/dist/chunk-CDLYRFDE.js.map +0 -1
- package/dist/chunk-GVNQAGXB.js.map +0 -1
- package/dist/chunk-IH76P5R6.cjs.map +0 -1
- package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
- /package/dist/{chunk-QTKU6ULP.js.map → chunk-BVFRD34B.js.map} +0 -0
- /package/dist/{chunk-2V4SACHE.js.map → chunk-L6IBKPVM.js.map} +0 -0
- /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 {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
} from './../
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
|
|
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=
|
|
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
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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
|
-
<
|
|
347
|
+
<ChatMessageRow
|
|
273
348
|
key={`body-${i}-${turn.slice(0, 24)}`}
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
<
|
|
444
|
+
<ChatMessageRow
|
|
371
445
|
key={eng.id}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
{
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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.
|