@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.
- package/dist/{chunk-2V4SACHE.js → chunk-EKBM4FHK.js} +2 -2
- package/dist/{chunk-VC3ND5RB.js → chunk-EWA2NFUR.js} +2 -2
- package/dist/{chunk-IH76P5R6.cjs → chunk-FZZBCRID.cjs} +8 -86
- package/dist/chunk-FZZBCRID.cjs.map +1 -0
- package/dist/{chunk-K4DFAVSO.cjs → chunk-GE64T3JT.cjs} +9 -9
- package/dist/{chunk-K4DFAVSO.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
- package/dist/{chunk-ZGTDUPTW.cjs → chunk-L5RSJE2I.cjs} +2318 -1136
- package/dist/chunk-L5RSJE2I.cjs.map +1 -0
- package/dist/{chunk-QTKU6ULP.js → chunk-OHOUSDAY.js} +2 -2
- package/dist/{chunk-572WQWIX.cjs → chunk-S4SVD5JI.cjs} +9 -9
- package/dist/{chunk-572WQWIX.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
- package/dist/{chunk-GVNQAGXB.js → chunk-SWIR5EB2.js} +3 -81
- package/dist/chunk-SWIR5EB2.js.map +1 -0
- package/dist/{chunk-SMCG2CCC.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
- package/dist/{chunk-SMCG2CCC.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
- package/dist/{chunk-CDLYRFDE.js → chunk-V5JY5RSY.js} +5710 -4528
- package/dist/chunk-V5JY5RSY.js.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/embeddable-chat.d.ts +13 -0
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
- package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
- 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/chat/types/unified-chat-state.types.d.ts +81 -0
- package/dist/components/chat/types/unified-chat-state.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 +78 -52
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +31 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +7 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
- package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
- package/dist/components/navigation/app-layout.d.ts +9 -1
- package/dist/components/navigation/app-layout.d.ts.map +1 -1
- package/dist/components/navigation/header-mingo-button.d.ts +21 -0
- package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
- package/dist/components/navigation/index.cjs +24 -2
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +5 -1
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +23 -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 +30 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +29 -1
- package/dist/utils/embed-authed-fetch.d.ts +80 -0
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/index.cjs +129 -9
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +129 -10
- 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 +2 -2
- 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/embeddable-chat.tsx +154 -37
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
- package/src/components/chat/hooks/use-slash-commands.ts +10 -1
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
- package/src/components/chat/hooks/use-unified-chat.ts +59 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/types/component.types.ts +8 -1
- package/src/components/chat/types/unified-chat-state.types.ts +116 -0
- package/src/components/navigation/app-header.tsx +23 -0
- package/src/components/navigation/app-layout-drawer.tsx +620 -0
- package/src/components/navigation/app-layout.tsx +65 -26
- package/src/components/navigation/header-mingo-button.tsx +58 -0
- package/src/components/navigation/index.ts +17 -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/stories/AppLayoutDrawer.stories.tsx +228 -0
- package/src/styles/app-globals.css +13 -0
- package/src/utils/.embed-authed-fetch.md +7 -0
- package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
- package/src/utils/embed-authed-fetch.ts +247 -7
- package/src/utils/index.ts +5 -1
- 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-2V4SACHE.js.map → chunk-EKBM4FHK.js.map} +0 -0
- /package/dist/{chunk-VC3ND5RB.js.map → chunk-EWA2NFUR.js.map} +0 -0
- /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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|