'use client'
/**
* `` — the expanded view of a single ticket.
*
* Extracted from the original `ticket-row.tsx` so both compositions
* share it:
* - Lib's `TicketRow` (compact `` summary + drawer
* beneath; what third-party embedders use via `TicketCenter`).
* - Hub's `` (the DevSection-style card chrome on the
* openframe `/tickets` page).
*
* The drawer owns everything BELOW the summary tile:
* 1. Metadata strip (ticket #, priority, pipeline, company, updated)
* 2. Conversation timeline (``) — original body
* turns + Note engagements + attachments
* 3. Status-dependent actions (composer + close OR reopen)
*
* State is local to this component (composer text, attachment bag,
* close-confirm dialog). The parent owns the ticket data + mutation
* callbacks; we don't reach into the QueryClient.
*/
import { useEffect, useRef } from 'react'
import { useStickToBottom } from 'use-stick-to-bottom'
import { Button } from './../ui/button'
import { useChatIdentity } from './../chat/hooks/use-chat-identity'
import {
ChatMessageRow,
ChatMessageRowSkeleton,
} from './../chat/chat-message-row'
import { EmptyState } from './../empty-state'
import {
TicketAttachmentsList,
type TicketAttachment,
} from './../ui/ticket-attachments-list'
import { SquareAvatar } from './../ui/square-avatar'
import { formatRelativeTime } from './../../utils/date-utils'
import { useTicketEngagements } from './hooks/use-ticket-engagements'
import type {
TicketEngagementFile,
} from './hooks/use-ticket-engagements'
import { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card'
import { useOptionalTicketLive } from './ticket-live-provider'
import { TicketReplyComposer } from './ticket-reply-composer'
import type {
AnyTicket,
TicketAssignedOwner,
MappedTicketActionError,
} from './types'
import { isOptimistic, TICKET_MARK_READ_DEBOUNCE_MS } from './types'
/** Identity bundle threaded through the action callbacks: local mirror
* UUID + HubSpot external_id. Actions send `external_id` to HubSpot
* (the only id it recognizes) and use `id` for the React-side mutex +
* TanStack cache. */
export type TicketRef = { id: string; external_id: string }
export interface TicketDetailDrawerProps {
ticket: AnyTicket
busy: boolean
supportSystemDown: boolean
/** Single combined "reply" — text + optional attachments delivered as
* ONE Note engagement. */
onSendMessage: (
ticket: TicketRef,
text: string,
attachments: import('./../chat/utils/chat-attachment-markdown').ChatAttachment[],
) => Promise
onClose: (ticket: TicketRef, resolution?: string) => Promise
onReopen: (ticket: TicketRef) => Promise
/** Called after a successful close/reopen so the parent can collapse
* the drawer (status flipped — current action set is now stale). */
onActionCollapsed: () => void
/** Persisted reply-failure surface — when non-null the drawer renders
* an inline banner above the composer with the mapped copy + a
* dismiss control. Distinct from the transient toast; the banner
* stays visible so the customer can locate the failed draft after
* the toast disappears. Cleared on the next successful send. */
replyError?: MappedTicketActionError | null
/** Dismiss-X handler for the banner. Parent calls
* `actions.clearReplyError(ticket.external_id)`. */
onClearReplyError?: () => void
}
export function TicketDetailDrawer({
ticket,
busy,
supportSystemDown,
onSendMessage,
onClose,
onReopen,
onActionCollapsed,
replyError,
onClearReplyError,
}: TicketDetailDrawerProps) {
const isClosed = (ticket.status ?? '').toUpperCase() === 'CLOSED'
return (
{/* Assignee header — surfaces who's looking at this ticket on the
support side. Populated server-side via `attachOwnerProfiles`;
falls back to "Unassigned" when no agent is assigned OR when
the owner couldn't be resolved (deleted between ticket update
+ next owners reconcile). */}
{/* Linked ClickUp delivery — rendered only when the server's
`attachClickupTasks` step populated `ticket.clickup`. Customer
tickets with no linked task skip this entirely. The card itself
links out to ClickUp with the per-status color badge so the
customer can follow the delivery progress. */}
{ticket.clickup && (
)}
Conversation
{/* Reply-failure banner — populated by `useTicketActions` when
the last sendMessage attempt for THIS ticket failed with a
reply-specific code. Rendered above the composer/reopen so
the customer sees it in context of their failed draft. Open
(composer) actions still allow Retry; the closed (reopen)
state still shows the banner because the user might have
tried to reply to a then-closing ticket. */}
{replyError && (
undefined)}
/>
)}
{isClosed ? (
) : (
)}
)
}
/**
* Render the ticket conversation as a chronological list of
* `` cards inside a single bordered container.
*
* Top: the original ticket description (`ticket.body`). Below: every
* Note engagement attached to the ticket via `useTicketEngagements` —
* each with its own attachments rendered through the shared
* `` (no more 📎-emoji chips).
*
* Legacy tickets whose old comments STILL live inside `ticket.body`
* (joined by ` --- `) split on that delimiter so the historical
* conversation surfaces correctly during the transition.
*
* Scroll behavior — INTENTIONALLY NONE. The drawer grows with the
* conversation; the page scrolls. The previous `max-h-96 overflow-y-auto`
* created two competing scroll surfaces (inner + page) which felt
* janky on long threads and hid the composer on short ones. 2026
* helpdesk best practice (UXPin / Coveo research) is a single
* threaded surface that flows with the page.
*/
// Bounded quantifiers (`{1,16}`) protect against the polynomial-time
// backtracking class CodeQL flags for unbounded `\s+` on user input.
// 16 chars of leading/trailing whitespace around `---` is far more
// than any composed ticket body needs, so no real input is rejected.
const TURN_SEPARATOR_RE = /[\s]{1,16}---[\s]{1,16}/g
// Slack-channel feed framing — ported from the hub's live community feed
// (`components/slack/chat-interface.tsx`: `:62` bounded card, `:83` padding +
// `overflow-y-auto`, `:85` `gap-4 md:gap-6` message column). Single source
// within the ticket feed; the delivery `DevCardRowSkeletonList` keeps its own
// (separate, untouched) frame literal. `max-h` is responsive (vs Slack's fixed
// height).
const TICKET_FEED_FRAME =
'bg-ods-card border border-ods-border rounded-[6px] overflow-y-auto w-full'
// FIXED height for EVERY state (skeleton, content, empty) — the Slack feed uses
// a fixed-height box too (`chat-interface.tsx:62`). Fixed (not `max-h`) is the
// fix for the "open shows 1 message, then the container grows as engagements
// land" jank: the feed is its final size from first paint, so loaded content
// just fills/scrolls inside it — the box never resizes.
const TICKET_FEED_HEIGHT = 'h-[60vh] md:h-[420px]'
const TICKET_FEED_INNER = 'flex flex-col gap-4 md:gap-6 px-4 md:px-6 py-4 md:py-6'
// Enough skeleton rows to fill the fixed height (avatar 40px + header + 2 body
// lines + gap-6) so the loading state looks like a full conversation.
const TICKET_FEED_SKELETON_ROWS = 6
function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
const identity = useChatIdentity()
// Optimistic placeholders don't have a real external_id yet — skip
// the engagement fetch until the real ticket lands.
const externalId = isOptimistic(ticket) ? null : ticket.external_id
// Live conversation refresh is PUSH-driven: `TicketLiveProvider`
// invalidates `['ticket-engagements', id]` on stream events, so new
// agent replies surface without any interval (polling was deleted
// 2026-08; `refetchOnWindowFocus` remains the no-stream fallback).
const { engagements, isLoading } = useTicketEngagements(externalId, !!externalId)
// Read-state lifecycle — this panel mounts exactly while the drawer is
// open, so it owns: register the open ticket (provider suppresses
// unread bumps + masks the row), markRead on open, debounced markRead
// as new messages stream in, flush on close. The provider's callbacks
// are stable (useCallback) so these effects don't re-fire on unrelated
// context-value changes.
const live = useOptionalTicketLive()
const setOpenTicketId = live?.setOpenTicketId
const markRead = live?.markRead
const markReadDebounceRef = useRef | null>(null)
useEffect(() => {
if (!externalId || !setOpenTicketId || !markRead) return
setOpenTicketId(externalId)
void markRead(externalId)
return () => {
// Flush a pending debounced markRead on close so a reply that
// arrived moments before closing is still receipted.
if (markReadDebounceRef.current) {
clearTimeout(markReadDebounceRef.current)
markReadDebounceRef.current = null
void markRead(externalId)
}
setOpenTicketId(null)
}
}, [externalId, setOpenTicketId, markRead])
const engagementsCount = engagements.length
useEffect(() => {
if (!externalId || !markRead || engagementsCount === 0) return
if (markReadDebounceRef.current) clearTimeout(markReadDebounceRef.current)
markReadDebounceRef.current = setTimeout(() => {
markReadDebounceRef.current = null
void markRead(externalId)
}, TICKET_MARK_READ_DEBOUNCE_MS)
}, [engagementsCount, externalId, markRead])
// Slack-style auto-tail (same lib mechanism `ChatMessageList` uses): jump to
// the newest message on open (`initial:'instant'`), smooth-scroll on a new
// reply. Called unconditionally here, BEFORE the empty/loading early-returns
// (Rules of Hooks); the refs attach ONLY to the content branch's scroll frame
// + column — never the cold-start skeleton (refs there would snap to skeleton
// height, then again to real content). This inner scroll is a SEPARATE
// container from `HelpCenterCard`'s page-level expand-scroll, so it never
// fights the "scroll to top of the ticket card" behavior.
const { scrollRef, contentRef } = useStickToBottom({ initial: 'instant', resize: 'smooth' })
const bodyTurns = ticket.body
? ticket.body.split(TURN_SEPARATOR_RE).map((t) => t.trim()).filter(Boolean)
: []
// Suppress `bodyTurns[0]` ("Original message") when the engagement
// timeline already has a customer-authored message whose body
// matches it. The channel-first create path in the hub writes the
// customer's message body BOTH into `hubspot_tickets.content` AND
// into the first `hubspot_ticket_conversation_messages` row — pre-
// 2026-05-29 the bot-intake-burst filter on the server dropped the
// first-customer message from engagements, so `bodyTurns[0]` was
// the only render. With channel-first, the engagement survives and
// both surfaces render the same text. Drop the redundant
// "Original message" turn when we detect that overlap.
//
// Only `bodyTurns[0]` is conditional. Subsequent turns ("Update N",
// "[Resolution]") come from `update_ticket.content_addendum` and
// are NEVER customer-written, so the engagement timeline can't
// match them. Leave their indices intact so `Update 1` still
// labels as such when `bodyTurns[0]` is suppressed.
const customerEngagementBodies = new Set(
engagements
.filter((e) => e.authorRole === 'customer')
.map((e) => (e.body ?? '').trim())
.filter(Boolean),
)
const suppressBodyTurnZero =
bodyTurns.length > 0 &&
customerEngagementBodies.has(bodyTurns[0])
// Customer name resolution precedence:
// 1. LIVE chat identity (`identity.user.name`) — when the viewer
// is the ticket's own customer. Always fresh.
// 2. Mirror's `customer_name` — the HubSpot contact's display
// name, captured by the ticket sync. Falls back here when the
// viewer is NOT the customer (admin browsing / multi-contact
// second viewer) so the customer bubble still shows the real
// person's name instead of "Customer" generic.
// 3. Session email — last resort.
// 4. "You" — anonymous viewer.
const sessionEmailLower = identity.user?.email?.trim().toLowerCase() ?? null
const isViewerTheCustomer =
!!sessionEmailLower &&
ticket.customer_emails.some((e) => e.trim().toLowerCase() === sessionEmailLower)
const viewerName = identity.user?.name?.trim() || null
const ticketCustomerName = ticket.customer_name?.trim() || null
const customerName =
(isViewerTheCustomer ? viewerName : null) ||
ticketCustomerName ||
viewerName ||
identity.user?.email ||
'You'
const customerAvatar = isViewerTheCustomer
? identity.user?.avatarUrl ?? undefined
: undefined
// Loading takes precedence over partial content — this is the fix for the
// "open shows 1 message, then the rest load and the box grows" jank. The
// ticket BODY is available synchronously, but the engagement timeline is
// fetched on open (caches off → EVERY open refetches). Rendering the body
// alone and then appending engagements as they arrive is the pop-in/grow the
// user hit. Instead: show the FULL-HEIGHT skeleton until the fetch settles,
// THEN render the whole conversation at once. Fixed height + skeleton-first =
// zero reflow and no partial render. `isLoading` (not `isFetching`) is true
// only on a cold open with no data yet — so a background refetch after sending
// a reply does NOT flash the skeleton; the new row just appends.
if (isLoading) {
// NO scroll refs here — they attach only to the real-content branch (refs on
// the skeleton would snap-to-bottom on skeleton height, then again on content).
return (
{/* Customer-authored description + any legacy `---`-joined
comments. Always rendered ABOVE the engagement timeline as
"Original message" because the server's intake-burst filter
(see `filterCustomerVisibleTimeline` in
`hubspot-conversations-utils.ts`) drops the customer's first
message from engagements when it was part of the HubSpot
Custom Channel bot intake — bodyTurns IS the canonical
original for those tickets. For tickets created without bot
intake (admin-created, email channel) bodyTurns shows the
manually-entered description and engagements show subsequent
replies — same flow, no duplication. */}
{bodyTurns.map((turn, i) => {
// Drop the redundant first turn when the engagement timeline
// below already renders the same customer-authored body. See
// `suppressBodyTurnZero` derivation above for the rationale.
if (i === 0 && suppressBodyTurnZero) return null
const isResolution = turn.startsWith('[Resolution]')
const text = isResolution ? turn.replace(/^\[Resolution\]\s*/, '') : turn
// Body turns don't carry per-turn timestamps — `ticket.body` is a
// single content field that HubSpot appends to. They render as the
// customer's own messages (no role chip — the Slack-channel feed has
// no role labels), oldest-first above the engagement timeline.
return (
)
})}
{/* Engagement timeline — interleaves customer-authored Custom
Channel messages (authorRole='customer') and team-authored
Notes (authorRole='support').
ATTRIBUTION RULES (per repo convention):
- CUSTOMER messages whose sender email matches the
current chat-identity user → render BOTH name AND
avatar LIVE from `identity.user.*` (1:1 from the
X-Chat-First-Name + X-Chat-Last-Name + X-Chat-Avatar-Url
headers that drive the identity webservice). This is
the source of truth for the logged-in user; we never
query `profiles` for customer display info.
- CUSTOMER messages from a DIFFERENT email (rare — the
/tickets surface only shows the current user's own
threads) → fall back to whatever the mirror has
(eng.authorName / eng.authorEmail), no profile lookup.
- SUPPORT/Note messages → the server has already
resolved `hubspot_owner_id` → owner email → `profiles`
row in `list-engagements`. `eng.authorName` +
`eng.authorAvatarUrl` carry the matched employee's
display info. When the owner isn't a known Flamingo
employee, both fields are null and we fall back to
the generic "Support team" treatment. */}
{engagements.map((eng) => {
const isCustomer = eng.authorRole === 'customer'
// Per-message own-reply check: the server populates `authorId`
// with the message sender's email (resolved server-side via
// the HubSpot Conversations actor batch-read for Custom
// Channel visitor messages). When that email matches the
// session viewer's email, the bubble is the viewer's OWN
// reply and renders with LIVE chat identity (name + avatar
// from chat-auth headers).
const isOwnReply =
isCustomer &&
!!eng.authorId &&
!!identity.user?.email &&
eng.authorId.toLowerCase() === identity.user.email.toLowerCase()
let author: string
let avatarSrc: string | undefined
if (isCustomer && isOwnReply) {
// Live identity — 1:1 from chat auth headers.
author = identity.user?.name?.trim() || customerName
avatarSrc = identity.user?.avatarUrl ?? undefined
} else if (isCustomer) {
// Customer bubble whose sender email isn't the current
// session viewer. Two sub-cases:
// (a) Same customer as the ticket but viewed by an admin
// (or no sender_email on the engagement at all — the
// Conversations API leaves it null on Custom Channels).
// Use `ticket.customer_name` from the mirror — that's
// the canonical HubSpot contact name for THIS ticket.
// (b) Multi-contact ticket (CC/BCC) — a different customer
// email appears here. We fall back to the same
// `ticket.customer_name` rather than leak the second
// contact's address; close enough for the rare case.
author = ticketCustomerName || 'Customer'
avatarSrc = undefined
} else if (eng.authorName && eng.authorAvatarUrl) {
// Resolved Flamingo employee — server matched the HubSpot
// owner's email against `profiles` AND has an avatar to
// prove it. Avatar presence IS the trust signal: only
// owner-resolved employees carry one; raw HubSpot
// `sender_name` (bots, integrations, system actors,
// unmatched humans) carries name without avatar and gets
// the generic "Support team" treatment so we never
// attribute a customer-facing bubble to a bot string
// ("HubSpot Bot", "Slack Integration", etc.).
author = eng.authorName
avatarSrc = eng.authorAvatarUrl
} else {
// Unmatched / unknown / bot / integration / system actor —
// generic fallback. Customer doesn't need to see internal
// tool branding (which has the customer "talking to" a bot
// string instead of a person).
author = 'Support team'
avatarSrc = undefined
}
// Role label: every engagement is a customer-visible
// Conversations message (customer ↔ agent on the Custom
// Channel). There are no internal Notes on this surface
// anymore — the read path explicitly filters them. So
// "Reply" for BOTH sides. The previous "Note" label for
// support bubbles was a legacy artifact from when Notes
// were rendered and made customers think their support
// engineer was leaving internal comments on their ticket.
const engAttachments = mapEngagementAttachments(eng.attachments)
return (
0 ? (
) : null
}
/>
)
})}
{/* No trailing refetch skeleton in the tailing feed: a skeleton mounted
inside `contentRef` on a background refetch would make the auto-tail
smooth-scroll to the skeleton and then again to the real row (a
double-jump). The smooth-tail to the appended real reply IS the
feedback. (Removed the former background-refetch rows={1} skeleton.) */}
)
}
/** Map the engagement file shape to the lib's canonical
* `TicketAttachment` so we can hand it straight to
* ``. Engagement `url` becomes a
* window.open-style download click; missing names degrade to
* `file-` so the chip never renders an empty label. */
function mapEngagementAttachments(
files: TicketEngagementFile[],
): TicketAttachment[] {
return files.map((f) => ({
id: f.id,
fileName: f.name ?? `file-${f.id}`,
fileSize: f.size ? formatBytes(f.size) : '',
// Show an inline thumbnail for image attachments (the signed `url` is a
// viewable URL). Non-images fall back to the file-type icon. SquareAvatar
// degrades to initials on a broken/expired image URL.
thumbnailSrc:
f.url && (f.mime?.startsWith('image/') ?? false) ? f.url : undefined,
onDownload: f.url
? () => window.open(f.url!, '_blank', 'noopener,noreferrer')
: undefined,
}))
}
function formatBytes(size: number): string {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
/** Strip the redundant `Attachments:\n\n filename.png\n filename2.png`
* preamble that the server appends to Note engagement bodies. We
* already render the same files through `` with
* proper icons + download buttons — showing the raw filename list
* again above the chip strip is duplicate noise. The regex matches
* ANY trailing block that starts with "Attachments:" (case-insensitive,
* optional leading whitespace) and consumes everything to end-of-string,
* so server-side wording tweaks like "Attachments (3):" still strip
* cleanly. Idempotent — a body with no preamble passes through
* untouched. */
const ATTACHMENTS_PREAMBLE_RE = /\s*\n\s*Attachments\b[^]*$/i
function stripAttachmentsPreamble(body: string): string {
return body.replace(ATTACHMENTS_PREAMBLE_RE, '').trim()
}
function ReopenAction({
ticketRef,
busy,
supportSystemDown,
onReopen,
onActionCollapsed,
}: {
ticketRef: TicketRef
busy: boolean
supportSystemDown: boolean
onReopen: TicketDetailDrawerProps['onReopen']
onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']
}) {
const handleReopen = async () => {
// Intentionally do NOT call `onActionCollapsed()` here. Pre-PR #1053
// every reopen was followed by a full list refetch which removed
// the (now-OPEN) row from a `?status=closed` view, so collapsing
// the drawer hid the disappearance flash. After #1053+#1055 the
// row stays in the list with the optimistic in-place status
// update — collapsing the drawer now actively dismisses the
// ticket the user is working on. Keep it mounted; the badge flip
// is enough feedback. (Reported 2026-05-29.)
void (await onReopen(ticketRef))
}
return (
{/* Reopen is a secondary, reversible action — `outline` (not the filled
accent primary) so it reads as available without dominating the
closed-ticket view. */}
)
}
/**
* Persistent banner above the drawer composer/actions when the most
* recent customer reply failed with a reply-specific code (HUBSPOT_5XX
* / 400 / 404 / UNKNOWN). The transient toast already fired at the
* moment of failure; this banner stays until the next successful send
* OR the user dismisses it explicitly. Wording is sourced from
* `mapTicketActionError` so a future copy update lives in one place.
*
* 404_THREAD is the only terminal code in the set — the banner copy
* reads "open a new ticket" and Retry would just re-fail. We still
* render a Dismiss control instead of hiding Retry so the visual shape
* is uniform; the parent's composer continues to function for any
* non-thread-deletion reply path.
*/
function ReplyFailureBanner({
error,
onDismiss,
}: {
error: MappedTicketActionError
onDismiss: () => void
}) {
return (
{error.message}
)
}
/**
* Compact "Assigned to" row at the top of the drawer. Surfaces the
* support-side agent — name + avatar — so the customer knows who's
* looking at their ticket. Renders "Unassigned" when the ticket has no
* `hubspot_owner_id` OR when the owner couldn't be resolved against
* the mirror (deleted between ticket update + next reconcile).
*
* Avatar comes from the canonical `` so
* it picks up the initials-fallback + image-proxy behavior used
* everywhere else in the lib (matches the dev-section message-bubble
* avatars). No bespoke avatar markup.
*/
function AssignedAgentRow({
assignedOwner,
}: {
assignedOwner: TicketAssignedOwner | null
}) {
// Display label precedence:
// 1. `name` from the mirror (employee match OR HubSpot's first+last)
// 2. `email` local-part — covers HubSpot owners that exist but have
// no name (rare but real; the ticket IS assigned and rendering
// "Unassigned" would be misleading)
// 3. "Unassigned" — only when the ticket has no `assigned_to` OR
// the owner couldn't be resolved against the mirror at all
const trimmedName = assignedOwner?.name?.trim() || null
const emailFallback = assignedOwner?.email?.trim() || null
const displayLabel =
trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)
return (
Assigned to
{displayLabel ? (
{/* Avatar loads direct; `SquareAvatar`'s own onError falls back to initials. */}
{displayLabel}
) : (
Unassigned
)}