@flamingo-stack/openframe-frontend-core 0.0.214 → 0.0.215-snapshot.20260529010429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/{chunk-KDNGUYZM.cjs → chunk-2G3NXF6J.cjs} +17 -17
  2. package/dist/{chunk-KDNGUYZM.cjs.map → chunk-2G3NXF6J.cjs.map} +1 -1
  3. package/dist/{chunk-HWY35EAK.js → chunk-7PCP7YQR.js} +2 -2
  4. package/dist/{chunk-YZUUFTIK.cjs → chunk-D4MNFY67.cjs} +3 -2
  5. package/dist/chunk-D4MNFY67.cjs.map +1 -0
  6. package/dist/{chunk-IZL2KEH6.cjs → chunk-NGFP4RVL.cjs} +24 -24
  7. package/dist/{chunk-IZL2KEH6.cjs.map → chunk-NGFP4RVL.cjs.map} +1 -1
  8. package/dist/{chunk-5OWDOFKK.js → chunk-R6MLPU4A.js} +2 -2
  9. package/dist/{chunk-UKND27XC.js → chunk-WQZP3JIZ.js} +3 -2
  10. package/dist/{chunk-UKND27XC.js.map → chunk-WQZP3JIZ.js.map} +1 -1
  11. package/dist/components/chat/index.cjs +2 -2
  12. package/dist/components/chat/index.js +1 -1
  13. package/dist/components/contact/index.cjs +3 -3
  14. package/dist/components/contact/index.js +2 -2
  15. package/dist/components/features/index.cjs +2 -2
  16. package/dist/components/features/index.js +1 -1
  17. package/dist/components/index.cjs +49 -49
  18. package/dist/components/index.js +2 -2
  19. package/dist/components/navigation/index.cjs +2 -2
  20. package/dist/components/navigation/index.js +1 -1
  21. package/dist/components/tickets/help-center-card.d.ts +5 -1
  22. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  23. package/dist/components/tickets/hooks/use-ticket-actions.d.ts +8 -0
  24. package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
  25. package/dist/components/tickets/index.cjs +254 -103
  26. package/dist/components/tickets/index.cjs.map +1 -1
  27. package/dist/components/tickets/index.js +184 -33
  28. package/dist/components/tickets/index.js.map +1 -1
  29. package/dist/components/tickets/ticket-detail-drawer.d.ts +11 -2
  30. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  31. package/dist/components/tickets/types.d.ts +28 -1
  32. package/dist/components/tickets/types.d.ts.map +1 -1
  33. package/dist/components/ui/index.cjs +2 -2
  34. package/dist/components/ui/index.js +1 -1
  35. package/dist/index.cjs +2 -2
  36. package/dist/index.js +1 -1
  37. package/package.json +1 -1
  38. package/src/components/platform/ShellTypeBadge.tsx +2 -2
  39. package/src/components/tickets/help-center-card.tsx +8 -0
  40. package/src/components/tickets/help-center-list.tsx +2 -0
  41. package/src/components/tickets/hooks/use-ticket-actions.ts +139 -5
  42. package/src/components/tickets/ticket-detail-drawer.tsx +129 -1
  43. package/src/components/tickets/types.ts +32 -0
  44. package/dist/chunk-YZUUFTIK.cjs.map +0 -1
  45. /package/dist/{chunk-HWY35EAK.js.map → chunk-7PCP7YQR.js.map} +0 -0
  46. /package/dist/{chunk-5OWDOFKK.js.map → chunk-R6MLPU4A.js.map} +0 -0
@@ -55,6 +55,10 @@ export interface HelpCenterCardProps {
55
55
  onClose: TicketDetailDrawerProps['onClose']
56
56
  onReopen: TicketDetailDrawerProps['onReopen']
57
57
  onActionCollapsed: () => void
58
+ /** Persisted reply-failure banner — forwarded to the drawer. Parent
59
+ * (`HelpCenterList`) reads via `actions.replyErrorFor(external_id)`. */
60
+ replyError?: TicketDetailDrawerProps['replyError']
61
+ onClearReplyError?: TicketDetailDrawerProps['onClearReplyError']
58
62
  }
59
63
 
60
64
  export function HelpCenterCard({
@@ -67,6 +71,8 @@ export function HelpCenterCard({
67
71
  onClose,
68
72
  onReopen,
69
73
  onActionCollapsed,
74
+ replyError,
75
+ onClearReplyError,
70
76
  }: HelpCenterCardProps) {
71
77
  const optimistic = isOptimistic(ticket)
72
78
  const rawStatus = (ticket.status ?? 'OPEN').toUpperCase()
@@ -177,6 +183,8 @@ export function HelpCenterCard({
177
183
  onClose={onClose}
178
184
  onReopen={onReopen}
179
185
  onActionCollapsed={onActionCollapsed}
186
+ replyError={replyError}
187
+ onClearReplyError={onClearReplyError}
180
188
  />
181
189
  </div>
182
190
  )}
@@ -285,6 +285,8 @@ function HelpCenterListAuthed({
285
285
  onClose={actions.closeTicket}
286
286
  onReopen={actions.reopenTicket}
287
287
  onActionCollapsed={() => setExpandedTicketId(null)}
288
+ replyError={actions.replyErrorFor(ticket.external_id)}
289
+ onClearReplyError={() => actions.clearReplyError(ticket.external_id)}
288
290
  />
289
291
  ))}
290
292
  </div>
@@ -44,6 +44,17 @@ import {
44
44
 
45
45
  const TICKET_ACTION_ENDPOINT = '/api/chat/agent/ticket-action'
46
46
 
47
+ /** Codes that populate the inline reply-failure banner above the drawer
48
+ * composer. Other codes (system-down, ticket-gone, rate-limit) are
49
+ * full-row / full-system signals covered by the toast + supportSystemDown
50
+ * handling — surfacing them in the inline banner too would be redundant. */
51
+ const REPLY_BANNER_CODES: ReadonlySet<TicketActionErrorCode> = new Set<TicketActionErrorCode>([
52
+ 'HUBSPOT_5XX',
53
+ 'HUBSPOT_400_VALIDATION',
54
+ 'HUBSPOT_404_THREAD',
55
+ 'HUBSPOT_REPLY_UNKNOWN',
56
+ ])
57
+
47
58
  /** 3 attempts × backoff (cumulative ~21s wall-clock). After this we
48
59
  * drop the optimistic row and ask the user to reload. */
49
60
  const MIRROR_SYNC_BACKOFF_MS = [3_000, 6_000, 12_000] as const
@@ -125,6 +136,14 @@ export interface UseTicketActionsReturn {
125
136
  isSubmittingForm: boolean
126
137
  /** Per-row in-flight set (read-only). UI uses `isRowBusy(localId)`. */
127
138
  isRowBusy: (localId: string) => boolean
139
+ /** Most recent reply failure for a given ticket id (`external_id`).
140
+ * Drives the inline "couldn't send" banner above the composer in
141
+ * `<TicketDetailDrawer>`. Cleared on the next successful send OR
142
+ * via `clearReplyError(ticketId)`. */
143
+ replyErrorFor: (ticketExternalId: string) => MappedTicketActionError | null
144
+ /** Clear the persisted reply-failure banner for a ticket (e.g. when
145
+ * the user dismisses it or starts a new draft). */
146
+ clearReplyError: (ticketExternalId: string) => void
128
147
  }
129
148
 
130
149
  export function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {
@@ -151,6 +170,37 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
151
170
  }, [])
152
171
  const isRowBusy = useCallback((id: string) => busyRows.has(id), [busyRows])
153
172
 
173
+ // Persisted reply-failure banner state — keyed by the ticket's
174
+ // HubSpot `external_id`. The drawer reads `replyErrorFor(externalId)`
175
+ // and renders an inline "couldn't send — retry" banner above the
176
+ // composer. Cleared automatically on the next successful send and
177
+ // explicitly by the dismiss-X / "Retry" actions in the banner UI.
178
+ // Distinct from the transient toast — the banner persists so the
179
+ // user can locate their failed draft after dismissing the toast.
180
+ const [replyErrorByTicket, setReplyErrorByTicket] = useState<
181
+ Map<string, MappedTicketActionError>
182
+ >(() => new Map())
183
+ const setReplyError = useCallback(
184
+ (externalId: string, mapped: MappedTicketActionError | null) => {
185
+ setReplyErrorByTicket((prev) => {
186
+ const next = new Map(prev)
187
+ if (mapped) next.set(externalId, mapped)
188
+ else next.delete(externalId)
189
+ return next
190
+ })
191
+ },
192
+ [],
193
+ )
194
+ const replyErrorFor = useCallback(
195
+ (externalId: string): MappedTicketActionError | null =>
196
+ replyErrorByTicket.get(externalId) ?? null,
197
+ [replyErrorByTicket],
198
+ )
199
+ const clearReplyError = useCallback(
200
+ (externalId: string) => setReplyError(externalId, null),
201
+ [setReplyError],
202
+ )
203
+
154
204
  // Mirror-sync watcher controllers tracked by placeholder id so we can
155
205
  // abort prior watchers when a new submit lands AND so unmount cleans
156
206
  // them up without leaking setState calls. Single source of truth for
@@ -262,9 +312,15 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
262
312
  [queryClient, removeOptimistic, toast],
263
313
  )
264
314
 
315
+ // Last `surfaceError` mapping — sendMessage reads this immediately
316
+ // after the catch returns so it can decide whether to populate the
317
+ // inline reply banner. Cleared on every read by the consumer to
318
+ // prevent a stale failure from leaking into the next attempt.
319
+ const lastUpdateErrorRef = useRef<MappedTicketActionError | null>(null)
265
320
  const surfaceError = useCallback(
266
321
  (err: unknown, action: string): MappedTicketActionError => {
267
322
  const mapped = mapTicketActionError(err)
323
+ lastUpdateErrorRef.current = mapped
268
324
  if (mapped.supportSystemDown) onSupportSystemDown()
269
325
  toast({
270
326
  title: `Could not ${action}`,
@@ -302,6 +358,10 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
302
358
  // seconds via the mirror refetch. Drawer uses live chat
303
359
  // identity for own-replies during this window anyway.
304
360
  customer_name: null,
361
+ // No assignee until the real ticket lands. Drawer renders
362
+ // "Unassigned" for this brief window.
363
+ assigned_to: null,
364
+ assignedOwner: null,
305
365
  hubspot_updated_at: new Date().toISOString(),
306
366
  _optimistic: true,
307
367
  }
@@ -393,12 +453,18 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
393
453
  )
394
454
 
395
455
  const sendMessage = useCallback(
396
- (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
456
+ async (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
397
457
  const trimmed = text.trim()
398
458
  const hasText = trimmed.length > 0
399
459
  const hasFiles = attachments.length > 0
400
- if (!hasText && !hasFiles) return Promise.resolve(false)
401
- return updateTicket(
460
+ if (!hasText && !hasFiles) return false
461
+ // Clear any stale mapped error from a prior non-sendMessage action
462
+ // (closeTicket / reopenTicket) so the post-call read only picks up
463
+ // an error THIS sendMessage produced. Without this clear, a prior
464
+ // close-failure's mapped error could leak into the banner via the
465
+ // post-call `lastUpdateErrorRef.current` read.
466
+ lastUpdateErrorRef.current = null
467
+ const ok = await updateTicket(
402
468
  ticket,
403
469
  {
404
470
  ...(hasText ? { content_addendum: trimmed } : {}),
@@ -407,8 +473,33 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
407
473
  TOAST_COPY.comment_success,
408
474
  'send message',
409
475
  )
476
+ // Banner-state coupling: SUCCESS clears any stale failure banner
477
+ // for this ticket; FAILURE populates the banner ONLY for the
478
+ // reply-specific code subset (HUBSPOT_5XX / 400 / 404 / UNKNOWN).
479
+ // Other codes (TICKET_NOT_FOUND, HUBSPOT_DISCONNECTED, RATE_LIMITED)
480
+ // are full-row / full-system signals already covered by the
481
+ // existing toast + supportSystemDown handling — surfacing them in
482
+ // the inline banner too would be redundant.
483
+ if (ok) {
484
+ clearReplyError(ticket.external_id)
485
+ } else {
486
+ // Line 466's `.current = null` narrows the property to literal
487
+ // `null`. `tsc -p tsconfig.declarations.json` (declarations
488
+ // build, distinct from the `tsc --noEmit` pre-step) doesn't
489
+ // widen that narrowing across the `await updateTicket(...)`,
490
+ // so the read here is typed `never`. The runtime type IS
491
+ // `MappedTicketActionError | null` per the useRef declaration;
492
+ // the assertion just tells TS to honor it instead of the stale
493
+ // narrowing.
494
+ const mapped = lastUpdateErrorRef.current as MappedTicketActionError | null
495
+ if (mapped && REPLY_BANNER_CODES.has(mapped.code)) {
496
+ setReplyError(ticket.external_id, mapped)
497
+ }
498
+ lastUpdateErrorRef.current = null
499
+ }
500
+ return ok
410
501
  },
411
- [updateTicket],
502
+ [updateTicket, clearReplyError, setReplyError],
412
503
  )
413
504
 
414
505
  const closeTicket = useCallback(
@@ -439,8 +530,19 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
439
530
  reopenTicket,
440
531
  isSubmittingForm,
441
532
  isRowBusy,
533
+ replyErrorFor,
534
+ clearReplyError,
442
535
  }),
443
- [submitTicket, sendMessage, closeTicket, reopenTicket, isSubmittingForm, isRowBusy],
536
+ [
537
+ submitTicket,
538
+ sendMessage,
539
+ closeTicket,
540
+ reopenTicket,
541
+ isSubmittingForm,
542
+ isRowBusy,
543
+ replyErrorFor,
544
+ clearReplyError,
545
+ ],
444
546
  )
445
547
  }
446
548
 
@@ -512,6 +614,38 @@ export function mapTicketActionError(err: unknown): MappedTicketActionError {
512
614
  supportSystemDown: false,
513
615
  removeRowFromCache: false,
514
616
  }
617
+ case 'HUBSPOT_5XX':
618
+ return {
619
+ code: err.code,
620
+ message:
621
+ "We couldn't reach the support system. Your reply wasn't sent — please retry in a moment.",
622
+ supportSystemDown: false,
623
+ removeRowFromCache: false,
624
+ }
625
+ case 'HUBSPOT_400_VALIDATION':
626
+ return {
627
+ code: err.code,
628
+ message:
629
+ 'Your reply was rejected. Please rephrase or remove unsupported content and try again.',
630
+ supportSystemDown: false,
631
+ removeRowFromCache: false,
632
+ }
633
+ case 'HUBSPOT_404_THREAD':
634
+ return {
635
+ code: err.code,
636
+ message:
637
+ 'This conversation is no longer accepting replies. Open a new ticket to continue.',
638
+ supportSystemDown: false,
639
+ removeRowFromCache: false,
640
+ }
641
+ case 'HUBSPOT_REPLY_UNKNOWN':
642
+ return {
643
+ code: err.code,
644
+ message:
645
+ "Your reply didn't go through. Please retry.",
646
+ supportSystemDown: false,
647
+ removeRowFromCache: false,
648
+ }
515
649
  default:
516
650
  return {
517
651
  code: 'UNKNOWN',
@@ -46,12 +46,17 @@ import {
46
46
  ConversationCardRowSkeletonList,
47
47
  } from './../shared/dev-section/dev-card-row'
48
48
  import type { TicketAttachment } from './../ui/ticket-attachments-list'
49
+ import { SquareAvatar } from './../ui/square-avatar'
49
50
  import { useTicketEngagements } from './hooks/use-ticket-engagements'
50
51
  import type {
51
52
  TicketEngagementFile,
52
53
  } from './hooks/use-ticket-engagements'
53
54
  import { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card'
54
- import type { AnyTicket } from './types'
55
+ import type {
56
+ AnyTicket,
57
+ TicketAssignedOwner,
58
+ MappedTicketActionError,
59
+ } from './types'
55
60
  import { isOptimistic, TICKET_TEXT_MAX_CHARS } from './types'
56
61
 
57
62
  /** Identity bundle threaded through the action callbacks: local mirror
@@ -76,6 +81,15 @@ export interface TicketDetailDrawerProps {
76
81
  /** Called after a successful close/reopen so the parent can collapse
77
82
  * the drawer (status flipped — current action set is now stale). */
78
83
  onActionCollapsed: () => void
84
+ /** Persisted reply-failure surface — when non-null the drawer renders
85
+ * an inline banner above the composer with the mapped copy + a
86
+ * dismiss control. Distinct from the transient toast; the banner
87
+ * stays visible so the customer can locate the failed draft after
88
+ * the toast disappears. Cleared on the next successful send. */
89
+ replyError?: MappedTicketActionError | null
90
+ /** Dismiss-X handler for the banner. Parent calls
91
+ * `actions.clearReplyError(ticket.external_id)`. */
92
+ onClearReplyError?: () => void
79
93
  }
80
94
 
81
95
  export function TicketDetailDrawer({
@@ -86,10 +100,19 @@ export function TicketDetailDrawer({
86
100
  onClose,
87
101
  onReopen,
88
102
  onActionCollapsed,
103
+ replyError,
104
+ onClearReplyError,
89
105
  }: TicketDetailDrawerProps) {
90
106
  const isClosed = (ticket.status ?? '').toUpperCase() === 'CLOSED'
91
107
  return (
92
108
  <div className="bg-ods-card border-t border-ods-border px-4 py-4 flex flex-col gap-4">
109
+ {/* Assignee header — surfaces who's looking at this ticket on the
110
+ support side. Populated server-side via `attachOwnerProfiles`;
111
+ falls back to "Unassigned" when no agent is assigned OR when
112
+ the owner couldn't be resolved (deleted between ticket update
113
+ + next owners reconcile). */}
114
+ <AssignedAgentRow assignedOwner={ticket.assignedOwner} />
115
+
93
116
  {/* Linked ClickUp delivery — rendered only when the server's
94
117
  `attachClickupTasks` step populated `ticket.clickup`. Customer
95
118
  tickets with no linked task skip this entirely. The card itself
@@ -107,6 +130,19 @@ export function TicketDetailDrawer({
107
130
  </div>
108
131
 
109
132
  <div className="border-t border-ods-border pt-4">
133
+ {/* Reply-failure banner — populated by `useTicketActions` when
134
+ the last sendMessage attempt for THIS ticket failed with a
135
+ reply-specific code. Rendered above the composer/reopen so
136
+ the customer sees it in context of their failed draft. Open
137
+ (composer) actions still allow Retry; the closed (reopen)
138
+ state still shows the banner because the user might have
139
+ tried to reply to a then-closing ticket. */}
140
+ {replyError && (
141
+ <ReplyFailureBanner
142
+ error={replyError}
143
+ onDismiss={onClearReplyError ?? (() => undefined)}
144
+ />
145
+ )}
110
146
  {isClosed ? (
111
147
  <ReopenAction
112
148
  ticketRef={{ id: ticket.id, external_id: ticket.external_id }}
@@ -562,3 +598,95 @@ function OpenActions({
562
598
  </div>
563
599
  )
564
600
  }
601
+
602
+ /**
603
+ * Persistent banner above the drawer composer/actions when the most
604
+ * recent customer reply failed with a reply-specific code (HUBSPOT_5XX
605
+ * / 400 / 404 / UNKNOWN). The transient toast already fired at the
606
+ * moment of failure; this banner stays until the next successful send
607
+ * OR the user dismisses it explicitly. Wording is sourced from
608
+ * `mapTicketActionError` so a future copy update lives in one place.
609
+ *
610
+ * 404_THREAD is the only terminal code in the set — the banner copy
611
+ * reads "open a new ticket" and Retry would just re-fail. We still
612
+ * render a Dismiss control instead of hiding Retry so the visual shape
613
+ * is uniform; the parent's composer continues to function for any
614
+ * non-thread-deletion reply path.
615
+ */
616
+ function ReplyFailureBanner({
617
+ error,
618
+ onDismiss,
619
+ }: {
620
+ error: MappedTicketActionError
621
+ onDismiss: () => void
622
+ }) {
623
+ return (
624
+ <div
625
+ role="status"
626
+ aria-live="polite"
627
+ className="mb-3 flex items-start gap-3 rounded-md border border-ods-attention-red-error bg-ods-attention-red-error-secondary px-3 py-2 text-sm text-ods-attention-red-error"
628
+ >
629
+ <span className="font-medium leading-snug">{error.message}</span>
630
+ <Button
631
+ type="button"
632
+ variant="transparent"
633
+ onClick={onDismiss}
634
+ aria-label="Dismiss reply failure"
635
+ className="ml-auto px-2 py-0.5 text-xs font-medium uppercase tracking-wider text-ods-attention-red-error hover:bg-ods-attention-red-error/10 border-transparent"
636
+ >
637
+ Dismiss
638
+ </Button>
639
+ </div>
640
+ )
641
+ }
642
+
643
+ /**
644
+ * Compact "Assigned to" row at the top of the drawer. Surfaces the
645
+ * support-side agent — name + avatar — so the customer knows who's
646
+ * looking at their ticket. Renders "Unassigned" when the ticket has no
647
+ * `hubspot_owner_id` OR when the owner couldn't be resolved against
648
+ * the mirror (deleted between ticket update + next reconcile).
649
+ *
650
+ * Avatar comes from the canonical `<SquareAvatar variant="round">` so
651
+ * it picks up the initials-fallback + image-proxy behavior used
652
+ * everywhere else in the lib (matches the dev-section message-bubble
653
+ * avatars). No bespoke avatar markup.
654
+ */
655
+ function AssignedAgentRow({
656
+ assignedOwner,
657
+ }: {
658
+ assignedOwner: TicketAssignedOwner | null
659
+ }) {
660
+ // Display label precedence:
661
+ // 1. `name` from the mirror (employee match OR HubSpot's first+last)
662
+ // 2. `email` local-part — covers HubSpot owners that exist but have
663
+ // no name (rare but real; the ticket IS assigned and rendering
664
+ // "Unassigned" would be misleading)
665
+ // 3. "Unassigned" — only when the ticket has no `assigned_to` OR
666
+ // the owner couldn't be resolved against the mirror at all
667
+ const trimmedName = assignedOwner?.name?.trim() || null
668
+ const emailFallback = assignedOwner?.email?.trim() || null
669
+ const displayLabel =
670
+ trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)
671
+ return (
672
+ <div className="flex items-center gap-2 text-xs">
673
+ <span className="text-ods-text-secondary uppercase tracking-wider font-medium">
674
+ Assigned to
675
+ </span>
676
+ {displayLabel ? (
677
+ <span className="flex items-center gap-1.5 text-ods-text-primary font-medium">
678
+ <SquareAvatar
679
+ size="sm"
680
+ variant="round"
681
+ src={assignedOwner?.avatarUrl ?? undefined}
682
+ alt={displayLabel}
683
+ fallback={displayLabel}
684
+ />
685
+ {displayLabel}
686
+ </span>
687
+ ) : (
688
+ <span className="text-ods-text-secondary italic">Unassigned</span>
689
+ )}
690
+ </div>
691
+ )
692
+ }
@@ -51,9 +51,30 @@ export interface TicketData {
51
51
  * Channels, so this is the only reliable source for "what's the
52
52
  * customer's name." */
53
53
  customer_name: string | null
54
+ /** HubSpot owner id of the agent assigned to this ticket. Carried as
55
+ * raw id for debugging; rendering goes through `assignedOwner`. Null
56
+ * when unassigned. */
57
+ assigned_to: string | null
58
+ /** Resolved assigned-owner profile — name + email + avatar. Populated
59
+ * server-side via `attachOwnerProfiles` which joins through the
60
+ * `hubspot_owners` mirror to `profiles` by email. Drives the
61
+ * "Assigned to" attribution in the drawer header. Null when
62
+ * unassigned OR the owner couldn't be resolved (rare — only when
63
+ * the agent was deleted from HubSpot between the ticket update and
64
+ * the next owners reconcile). */
65
+ assignedOwner: TicketAssignedOwner | null
54
66
  hubspot_updated_at: string
55
67
  }
56
68
 
69
+ /** Resolved profile of a ticket's assigned agent — surfaced in the
70
+ * drawer header. Subset of the server's `MirroredOwnerProfile`
71
+ * trimmed to just the rendering fields. */
72
+ export interface TicketAssignedOwner {
73
+ name: string | null
74
+ email: string | null
75
+ avatarUrl: string | null
76
+ }
77
+
57
78
  /** Compact projection of a linked ClickUp task — matches the server's
58
79
  * `ClickupSummary` and aligns with `DeliveryItem` so the linked-card
59
80
  * on a ticket can render through the same `DeliveryRow` primitive used
@@ -122,6 +143,13 @@ export function isOptimistic(t: AnyTicket): t is OptimisticTicket {
122
143
  * Stable server-side error codes the ticket-action helpers route
123
144
  * through `mapTicketActionError`. Anything else is treated as a generic
124
145
  * server error.
146
+ *
147
+ * Reply-specific codes (`HUBSPOT_5XX` / `HUBSPOT_400_VALIDATION` /
148
+ * `HUBSPOT_404_THREAD` / `HUBSPOT_REPLY_UNKNOWN`) drive the drawer
149
+ * banner that appears above the composer when a customer reply fails.
150
+ * Distinct from `HUBSPOT_DISCONNECTED` (whole-system) and
151
+ * `TICKET_NOT_FOUND` (terminal-row) — the reply codes are per-attempt
152
+ * + retryable except for `HUBSPOT_404_THREAD`.
125
153
  */
126
154
  export type TicketActionErrorCode =
127
155
  | 'PROPOSAL_NOT_CLAIMABLE'
@@ -130,6 +158,10 @@ export type TicketActionErrorCode =
130
158
  | 'HUBSPOT_DISCONNECTED'
131
159
  | 'RATE_LIMITED'
132
160
  | 'INVALID_TOOL_ARGS'
161
+ | 'HUBSPOT_5XX'
162
+ | 'HUBSPOT_400_VALIDATION'
163
+ | 'HUBSPOT_404_THREAD'
164
+ | 'HUBSPOT_REPLY_UNKNOWN'
133
165
  | 'UNKNOWN'
134
166
 
135
167
  export interface MappedTicketActionError {