@flamingo-stack/openframe-frontend-core 0.0.215 → 0.0.216

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 (228) hide show
  1. package/dist/chunk-2V4SACHE.js +302 -0
  2. package/dist/chunk-2V4SACHE.js.map +1 -0
  3. package/dist/chunk-572WQWIX.cjs +348 -0
  4. package/dist/chunk-572WQWIX.cjs.map +1 -0
  5. package/dist/{chunk-WT5JV2GS.cjs → chunk-5V6MSE3B.cjs} +39 -39
  6. package/dist/chunk-5V6MSE3B.cjs.map +1 -0
  7. package/dist/{chunk-WQZP3JIZ.js → chunk-CDLYRFDE.js} +1894 -1472
  8. package/dist/chunk-CDLYRFDE.js.map +1 -0
  9. package/dist/chunk-GVNQAGXB.js +232 -0
  10. package/dist/chunk-GVNQAGXB.js.map +1 -0
  11. package/dist/{chunk-P5EE2VJX.cjs → chunk-HOHDXYPR.cjs} +1 -1
  12. package/dist/chunk-HOHDXYPR.cjs.map +1 -0
  13. package/dist/chunk-IH76P5R6.cjs +232 -0
  14. package/dist/chunk-IH76P5R6.cjs.map +1 -0
  15. package/dist/{chunk-24KCAECR.cjs → chunk-JJR27M56.cjs} +3 -3
  16. package/dist/{chunk-24KCAECR.cjs.map → chunk-JJR27M56.cjs.map} +1 -1
  17. package/dist/chunk-K4DFAVSO.cjs +302 -0
  18. package/dist/chunk-K4DFAVSO.cjs.map +1 -0
  19. package/dist/{chunk-HICZPTRR.js → chunk-LCLTCCXS.js} +14 -14
  20. package/dist/chunk-LCLTCCXS.js.map +1 -0
  21. package/dist/{chunk-VFKQMAUF.cjs → chunk-OB45JHDY.cjs} +3 -3
  22. package/dist/{chunk-VFKQMAUF.cjs.map → chunk-OB45JHDY.cjs.map} +1 -1
  23. package/dist/{chunk-4XLJWX2N.js → chunk-ORJREQ2W.js} +4 -4
  24. package/dist/{chunk-7PCP7YQR.js → chunk-QTKU6ULP.js} +6 -6
  25. package/dist/{chunk-CIPO6DXK.js → chunk-QY75VKAS.js} +5 -5
  26. package/dist/{chunk-ZG2YY5E7.js → chunk-RFONYT63.js} +1 -1
  27. package/dist/chunk-RFONYT63.js.map +1 -0
  28. package/dist/{chunk-NGFP4RVL.cjs → chunk-SMCG2CCC.cjs} +30 -30
  29. package/dist/{chunk-NGFP4RVL.cjs.map → chunk-SMCG2CCC.cjs.map} +1 -1
  30. package/dist/{chunk-MX5MIFWA.js → chunk-UEBM4PC4.js} +5 -5
  31. package/dist/chunk-VC3ND5RB.js +348 -0
  32. package/dist/chunk-VC3ND5RB.js.map +1 -0
  33. package/dist/{chunk-UXZ3ZJ3M.cjs → chunk-XDPSSE4O.cjs} +4 -4
  34. package/dist/{chunk-UXZ3ZJ3M.cjs.map → chunk-XDPSSE4O.cjs.map} +1 -1
  35. package/dist/{chunk-D4MNFY67.cjs → chunk-ZGTDUPTW.cjs} +1316 -894
  36. package/dist/chunk-ZGTDUPTW.cjs.map +1 -0
  37. package/dist/components/chat/entity-cards/blog-card.d.ts +1 -1
  38. package/dist/components/chat/entity-cards/blog-card.d.ts.map +1 -1
  39. package/dist/components/chat/entity-cards/case-study-card.d.ts +1 -1
  40. package/dist/components/chat/entity-cards/case-study-card.d.ts.map +1 -1
  41. package/dist/components/chat/entity-cards/customer-interview-card.d.ts +1 -1
  42. package/dist/components/chat/entity-cards/customer-interview-card.d.ts.map +1 -1
  43. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  44. package/dist/components/chat/entity-cards/investor-update-card.d.ts +1 -1
  45. package/dist/components/chat/entity-cards/investor-update-card.d.ts.map +1 -1
  46. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts +1 -1
  47. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
  48. package/dist/components/chat/entity-cards/program-card.d.ts +1 -1
  49. package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
  50. package/dist/components/chat/entity-cards/use-entity-card-link.d.ts +14 -0
  51. package/dist/components/chat/entity-cards/use-entity-card-link.d.ts.map +1 -0
  52. package/dist/components/chat/entity-cards/use-entity-card-placeholder.d.ts +13 -0
  53. package/dist/components/chat/entity-cards/use-entity-card-placeholder.d.ts.map +1 -0
  54. package/dist/components/chat/index.cjs +11 -11
  55. package/dist/components/chat/index.js +10 -10
  56. package/dist/components/contact/index.cjs +12 -12
  57. package/dist/components/contact/index.js +11 -11
  58. package/dist/components/features/captions-url.d.ts +18 -0
  59. package/dist/components/features/captions-url.d.ts.map +1 -0
  60. package/dist/components/features/index.cjs +23 -11
  61. package/dist/components/features/index.cjs.map +1 -1
  62. package/dist/components/features/index.d.ts +2 -0
  63. package/dist/components/features/index.d.ts.map +1 -1
  64. package/dist/components/features/index.js +24 -12
  65. package/dist/components/features/mux-origins.cjs +10 -0
  66. package/dist/components/features/mux-origins.cjs.map +1 -0
  67. package/dist/components/features/mux-origins.d.ts +26 -0
  68. package/dist/components/features/mux-origins.d.ts.map +1 -0
  69. package/dist/components/features/mux-origins.js +7 -0
  70. package/dist/components/features/mux-origins.js.map +1 -0
  71. package/dist/components/features/notifications/index.d.ts +2 -0
  72. package/dist/components/features/notifications/index.d.ts.map +1 -1
  73. package/dist/components/features/notifications/notification-drawer.d.ts +2 -1
  74. package/dist/components/features/notifications/notification-drawer.d.ts.map +1 -1
  75. package/dist/components/features/notifications/notification-popups.d.ts +10 -0
  76. package/dist/components/features/notifications/notification-popups.d.ts.map +1 -0
  77. package/dist/components/features/notifications/notifications-context.d.ts +8 -1
  78. package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
  79. package/dist/components/features/notifications/types.d.ts +1 -0
  80. package/dist/components/features/notifications/types.d.ts.map +1 -1
  81. package/dist/components/features/use-video-warmup.d.ts +53 -0
  82. package/dist/components/features/use-video-warmup.d.ts.map +1 -0
  83. package/dist/components/icons/index.cjs +3 -3
  84. package/dist/components/icons/index.js +2 -2
  85. package/dist/components/icons-v2-generated/index.cjs +2 -2
  86. package/dist/components/icons-v2-generated/index.cjs.map +1 -1
  87. package/dist/components/icons-v2-generated/index.js +4 -4
  88. package/dist/components/index.cjs +132 -102
  89. package/dist/components/index.cjs.map +1 -1
  90. package/dist/components/index.d.ts +1 -0
  91. package/dist/components/index.d.ts.map +1 -1
  92. package/dist/components/index.js +94 -64
  93. package/dist/components/index.js.map +1 -1
  94. package/dist/components/navigation/index.cjs +11 -11
  95. package/dist/components/navigation/index.js +10 -10
  96. package/dist/components/onboarding-guides/build-default-href.d.ts +15 -0
  97. package/dist/components/onboarding-guides/build-default-href.d.ts.map +1 -0
  98. package/dist/components/onboarding-guides/hooks/use-onboarding-guides.d.ts +28 -0
  99. package/dist/components/onboarding-guides/hooks/use-onboarding-guides.d.ts.map +1 -0
  100. package/dist/components/onboarding-guides/index.cjs +373 -0
  101. package/dist/components/onboarding-guides/index.cjs.map +1 -0
  102. package/dist/components/onboarding-guides/index.d.ts +25 -0
  103. package/dist/components/onboarding-guides/index.d.ts.map +1 -0
  104. package/dist/components/onboarding-guides/index.js +373 -0
  105. package/dist/components/onboarding-guides/index.js.map +1 -0
  106. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts +52 -0
  107. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -0
  108. package/dist/components/onboarding-guides/onboarding-guides-catalog-skeleton.d.ts +17 -0
  109. package/dist/components/onboarding-guides/onboarding-guides-catalog-skeleton.d.ts.map +1 -0
  110. package/dist/components/onboarding-guides/onboarding-guides-catalog-view.d.ts +43 -0
  111. package/dist/components/onboarding-guides/onboarding-guides-catalog-view.d.ts.map +1 -0
  112. package/dist/components/shared/doc-search/doc-search-bar.d.ts +59 -0
  113. package/dist/components/shared/doc-search/doc-search-bar.d.ts.map +1 -0
  114. package/dist/components/shared/doc-search/doc-search-result-row.d.ts +18 -0
  115. package/dist/components/shared/doc-search/doc-search-result-row.d.ts.map +1 -0
  116. package/dist/components/shared/doc-search/format-relative-path.d.ts +10 -0
  117. package/dist/components/shared/doc-search/format-relative-path.d.ts.map +1 -0
  118. package/dist/components/shared/doc-search/index.d.ts +8 -0
  119. package/dist/components/shared/doc-search/index.d.ts.map +1 -0
  120. package/dist/components/shared/doc-search/map-doc-search-results.d.ts +15 -0
  121. package/dist/components/shared/doc-search/map-doc-search-results.d.ts.map +1 -0
  122. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +37 -0
  123. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -0
  124. package/dist/components/shared/doc-search/types.d.ts +29 -0
  125. package/dist/components/shared/doc-search/types.d.ts.map +1 -0
  126. package/dist/components/shared/doc-search/use-doc-search.d.ts +46 -0
  127. package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -0
  128. package/dist/components/tickets/help-center-card.d.ts +5 -1
  129. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  130. package/dist/components/tickets/hooks/use-ticket-actions.d.ts +8 -0
  131. package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
  132. package/dist/components/tickets/index.cjs +316 -145
  133. package/dist/components/tickets/index.cjs.map +1 -1
  134. package/dist/components/tickets/index.js +237 -66
  135. package/dist/components/tickets/index.js.map +1 -1
  136. package/dist/components/tickets/ticket-detail-drawer.d.ts +11 -2
  137. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  138. package/dist/components/tickets/types.d.ts +50 -1
  139. package/dist/components/tickets/types.d.ts.map +1 -1
  140. package/dist/components/ui/file-manager/index.cjs +51 -51
  141. package/dist/components/ui/file-manager/index.cjs.map +1 -1
  142. package/dist/components/ui/file-manager/index.js +2 -2
  143. package/dist/components/ui/filter-pill-row.d.ts +20 -0
  144. package/dist/components/ui/filter-pill-row.d.ts.map +1 -0
  145. package/dist/components/ui/index.cjs +16 -14
  146. package/dist/components/ui/index.cjs.map +1 -1
  147. package/dist/components/ui/index.d.ts +1 -0
  148. package/dist/components/ui/index.d.ts.map +1 -1
  149. package/dist/components/ui/index.js +21 -19
  150. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  151. package/dist/contexts/chat-runtime-context.d.ts +42 -0
  152. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  153. package/dist/contexts/index.cjs +2 -2
  154. package/dist/contexts/index.js +1 -1
  155. package/dist/embed-shims/index.cjs +3 -3
  156. package/dist/embed-shims/index.cjs.map +1 -1
  157. package/dist/embed-shims/index.js +5 -5
  158. package/dist/hooks/index.cjs +6 -6
  159. package/dist/hooks/index.js +5 -5
  160. package/dist/index.cjs +28 -14
  161. package/dist/index.cjs.map +1 -1
  162. package/dist/index.js +59 -45
  163. package/dist/utils/dev-sections/openframe-dev-sections.d.ts +2 -2
  164. package/dist/utils/dev-sections/openframe-dev-sections.d.ts.map +1 -1
  165. package/dist/utils/index.cjs +11 -5
  166. package/dist/utils/index.cjs.map +1 -1
  167. package/dist/utils/index.js +11 -5
  168. package/dist/utils/index.js.map +1 -1
  169. package/package.json +13 -1
  170. package/src/components/chat/entity-cards/blog-card.tsx +17 -5
  171. package/src/components/chat/entity-cards/case-study-card.tsx +23 -1
  172. package/src/components/chat/entity-cards/customer-interview-card.tsx +23 -1
  173. package/src/components/chat/entity-cards/dispatch.tsx +21 -0
  174. package/src/components/chat/entity-cards/investor-update-card.tsx +23 -1
  175. package/src/components/chat/entity-cards/onboarding-guide-card.tsx +30 -4
  176. package/src/components/chat/entity-cards/program-card.tsx +17 -3
  177. package/src/components/chat/entity-cards/use-entity-card-link.ts +66 -0
  178. package/src/components/chat/entity-cards/use-entity-card-placeholder.ts +50 -0
  179. package/src/components/features/captions-url.ts +25 -0
  180. package/src/components/features/index.ts +2 -0
  181. package/src/components/features/mux-origins.ts +27 -0
  182. package/src/components/features/notifications/index.ts +2 -0
  183. package/src/components/features/notifications/notification-drawer.tsx +100 -16
  184. package/src/components/features/notifications/notification-popups.tsx +105 -0
  185. package/src/components/features/notifications/notifications-context.tsx +16 -0
  186. package/src/components/features/notifications/types.ts +1 -0
  187. package/src/components/features/use-video-warmup.ts +176 -0
  188. package/src/components/index.ts +5 -0
  189. package/src/components/onboarding-guides/build-default-href.ts +16 -0
  190. package/src/components/onboarding-guides/hooks/use-onboarding-guides.ts +90 -0
  191. package/src/components/onboarding-guides/index.ts +39 -0
  192. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +215 -0
  193. package/src/components/onboarding-guides/onboarding-guides-catalog-skeleton.tsx +62 -0
  194. package/src/components/onboarding-guides/onboarding-guides-catalog-view.tsx +230 -0
  195. package/src/components/shared/doc-search/doc-search-bar.tsx +100 -0
  196. package/src/components/shared/doc-search/doc-search-result-row.tsx +73 -0
  197. package/src/components/shared/doc-search/format-relative-path.ts +17 -0
  198. package/src/components/shared/doc-search/index.ts +24 -0
  199. package/src/components/shared/doc-search/map-doc-search-results.ts +113 -0
  200. package/src/components/shared/doc-search/resolve-search-result-action.ts +68 -0
  201. package/src/components/shared/doc-search/types.ts +28 -0
  202. package/src/components/shared/doc-search/use-doc-search.ts +263 -0
  203. package/src/components/tickets/help-center-card.tsx +8 -0
  204. package/src/components/tickets/help-center-list.tsx +17 -3
  205. package/src/components/tickets/hooks/use-ticket-actions.ts +210 -14
  206. package/src/components/tickets/ticket-detail-drawer.tsx +145 -5
  207. package/src/components/tickets/types.ts +55 -0
  208. package/src/components/ui/filter-pill-row.tsx +72 -0
  209. package/src/components/ui/index.ts +1 -0
  210. package/src/components/ui/simple-markdown-renderer.tsx +24 -1
  211. package/src/components/ui/toaster.tsx +3 -3
  212. package/src/contexts/chat-runtime-context.tsx +41 -0
  213. package/src/stories/NotificationDrawer.stories.tsx +18 -2
  214. package/src/utils/dev-sections/openframe-dev-sections.ts +12 -5
  215. package/dist/chunk-2G3NXF6J.cjs +0 -521
  216. package/dist/chunk-2G3NXF6J.cjs.map +0 -1
  217. package/dist/chunk-D4MNFY67.cjs.map +0 -1
  218. package/dist/chunk-HICZPTRR.js.map +0 -1
  219. package/dist/chunk-P5EE2VJX.cjs.map +0 -1
  220. package/dist/chunk-R6MLPU4A.js +0 -521
  221. package/dist/chunk-R6MLPU4A.js.map +0 -1
  222. package/dist/chunk-WQZP3JIZ.js.map +0 -1
  223. package/dist/chunk-WT5JV2GS.cjs.map +0 -1
  224. package/dist/chunk-ZG2YY5E7.js.map +0 -1
  225. /package/dist/{chunk-4XLJWX2N.js.map → chunk-ORJREQ2W.js.map} +0 -0
  226. /package/dist/{chunk-7PCP7YQR.js.map → chunk-QTKU6ULP.js.map} +0 -0
  227. /package/dist/{chunk-CIPO6DXK.js.map → chunk-QY75VKAS.js.map} +0 -0
  228. /package/dist/{chunk-MX5MIFWA.js.map → chunk-UEBM4PC4.js.map} +0 -0
@@ -39,11 +39,23 @@ import {
39
39
  type OptimisticTicket,
40
40
  type TicketActionErrorCode,
41
41
  type TicketData,
42
+ type TicketsCacheSlot,
42
43
  TOAST_COPY,
43
44
  } from '../types'
44
45
 
45
46
  const TICKET_ACTION_ENDPOINT = '/api/chat/agent/ticket-action'
46
47
 
48
+ /** Codes that populate the inline reply-failure banner above the drawer
49
+ * composer. Other codes (system-down, ticket-gone, rate-limit) are
50
+ * full-row / full-system signals covered by the toast + supportSystemDown
51
+ * handling — surfacing them in the inline banner too would be redundant. */
52
+ const REPLY_BANNER_CODES: ReadonlySet<TicketActionErrorCode> = new Set<TicketActionErrorCode>([
53
+ 'HUBSPOT_5XX',
54
+ 'HUBSPOT_400_VALIDATION',
55
+ 'HUBSPOT_404_THREAD',
56
+ 'HUBSPOT_REPLY_UNKNOWN',
57
+ ])
58
+
47
59
  /** 3 attempts × backoff (cumulative ~21s wall-clock). After this we
48
60
  * drop the optimistic row and ask the user to reload. */
49
61
  const MIRROR_SYNC_BACKOFF_MS = [3_000, 6_000, 12_000] as const
@@ -125,6 +137,14 @@ export interface UseTicketActionsReturn {
125
137
  isSubmittingForm: boolean
126
138
  /** Per-row in-flight set (read-only). UI uses `isRowBusy(localId)`. */
127
139
  isRowBusy: (localId: string) => boolean
140
+ /** Most recent reply failure for a given ticket id (`external_id`).
141
+ * Drives the inline "couldn't send" banner above the composer in
142
+ * `<TicketDetailDrawer>`. Cleared on the next successful send OR
143
+ * via `clearReplyError(ticketId)`. */
144
+ replyErrorFor: (ticketExternalId: string) => MappedTicketActionError | null
145
+ /** Clear the persisted reply-failure banner for a ticket (e.g. when
146
+ * the user dismisses it or starts a new draft). */
147
+ clearReplyError: (ticketExternalId: string) => void
128
148
  }
129
149
 
130
150
  export function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {
@@ -151,6 +171,37 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
151
171
  }, [])
152
172
  const isRowBusy = useCallback((id: string) => busyRows.has(id), [busyRows])
153
173
 
174
+ // Persisted reply-failure banner state — keyed by the ticket's
175
+ // HubSpot `external_id`. The drawer reads `replyErrorFor(externalId)`
176
+ // and renders an inline "couldn't send — retry" banner above the
177
+ // composer. Cleared automatically on the next successful send and
178
+ // explicitly by the dismiss-X / "Retry" actions in the banner UI.
179
+ // Distinct from the transient toast — the banner persists so the
180
+ // user can locate their failed draft after dismissing the toast.
181
+ const [replyErrorByTicket, setReplyErrorByTicket] = useState<
182
+ Map<string, MappedTicketActionError>
183
+ >(() => new Map())
184
+ const setReplyError = useCallback(
185
+ (externalId: string, mapped: MappedTicketActionError | null) => {
186
+ setReplyErrorByTicket((prev) => {
187
+ const next = new Map(prev)
188
+ if (mapped) next.set(externalId, mapped)
189
+ else next.delete(externalId)
190
+ return next
191
+ })
192
+ },
193
+ [],
194
+ )
195
+ const replyErrorFor = useCallback(
196
+ (externalId: string): MappedTicketActionError | null =>
197
+ replyErrorByTicket.get(externalId) ?? null,
198
+ [replyErrorByTicket],
199
+ )
200
+ const clearReplyError = useCallback(
201
+ (externalId: string) => setReplyError(externalId, null),
202
+ [setReplyError],
203
+ )
204
+
154
205
  // Mirror-sync watcher controllers tracked by placeholder id so we can
155
206
  // abort prior watchers when a new submit lands AND so unmount cleans
156
207
  // them up without leaking setState calls. Single source of truth for
@@ -262,9 +313,15 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
262
313
  [queryClient, removeOptimistic, toast],
263
314
  )
264
315
 
316
+ // Last `surfaceError` mapping — sendMessage reads this immediately
317
+ // after the catch returns so it can decide whether to populate the
318
+ // inline reply banner. Cleared on every read by the consumer to
319
+ // prevent a stale failure from leaking into the next attempt.
320
+ const lastUpdateErrorRef = useRef<MappedTicketActionError | null>(null)
265
321
  const surfaceError = useCallback(
266
322
  (err: unknown, action: string): MappedTicketActionError => {
267
323
  const mapped = mapTicketActionError(err)
324
+ lastUpdateErrorRef.current = mapped
268
325
  if (mapped.supportSystemDown) onSupportSystemDown()
269
326
  toast({
270
327
  title: `Could not ${action}`,
@@ -302,6 +359,10 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
302
359
  // seconds via the mirror refetch. Drawer uses live chat
303
360
  // identity for own-replies during this window anyway.
304
361
  customer_name: null,
362
+ // No assignee until the real ticket lands. Drawer renders
363
+ // "Unassigned" for this brief window.
364
+ assigned_to: null,
365
+ assignedOwner: null,
305
366
  hubspot_updated_at: new Date().toISOString(),
306
367
  _optimistic: true,
307
368
  }
@@ -366,13 +427,63 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
366
427
  ticket_id: ticket.external_id,
367
428
  } as unknown as Record<string, unknown>)
368
429
  toast(successCopy)
369
- // Invalidate BOTH ticket list (status / pipeline may have
370
- // changed) AND engagements (comments + attachments produce a
371
- // new Note that the timeline must pick up).
372
- await Promise.all([
373
- queryClient.invalidateQueries({ queryKey: ['tickets'] }),
374
- queryClient.invalidateQueries({ queryKey: ['ticket-engagements'] }),
375
- ])
430
+
431
+ // OPTIMISTIC in-place row update on the tickets cache.
432
+ //
433
+ // Previously this code called
434
+ // `queryClient.invalidateQueries({ queryKey: ['tickets'] })`
435
+ // which forced a full refetch. When the user is on a
436
+ // filtered view (e.g. ?status=open) and CLOSES a ticket from
437
+ // its drawer, the refetched list excludes the now-closed
438
+ // row, the parent `<HelpCenterCard>` for that row unmounts,
439
+ // and the inline drawer dies with it — user-facing bug
440
+ // "close button refreshes the whole page and dismisses the
441
+ // ticket I was working on" (reported 2026-05-29).
442
+ //
443
+ // The mutation already knows what changed (status, content
444
+ // addendum, attachments) — apply those fields in place
445
+ // across every `['tickets']` cache slot. The row stays in
446
+ // the list with the new badge; React doesn't reconcile away
447
+ // the card; the drawer stays mounted and the user can
448
+ // continue working.
449
+ //
450
+ // Filter-mismatch trade-off: a row that no longer matches a
451
+ // slot's filter (e.g. CLOSED row in ?status=open cache)
452
+ // stays visually until next manual refetch (filter change,
453
+ // page nav, manual reload). Acceptable — the user opted into
454
+ // the action; carrying their drawer through it is more
455
+ // important than instantly hiding the row.
456
+ const statusUpdate =
457
+ (serverArgs as { status?: 'OPEN' | 'CLOSED' }).status ?? null
458
+ if (statusUpdate) {
459
+ // The `useTicketsList` query (in `use-tickets-list.ts`)
460
+ // returns `FindTicketResponse` — an OBJECT shape
461
+ // `{ tickets: TicketData[], count, page, totalPages, ... }` —
462
+ // NOT a bare `TicketData[]`. The previous version of this
463
+ // callback assumed an array and crashed at runtime with
464
+ // `t.map is not a function` on every close/reopen
465
+ // (reported 2026-05-29 in prod). Project the nested
466
+ // tickets array, map, and reassemble the wrapper.
467
+ queryClient.setQueriesData<TicketsCacheSlot | undefined>(
468
+ { queryKey: ['tickets'] },
469
+ (prev) => {
470
+ if (!prev || !Array.isArray(prev.tickets)) return prev
471
+ let mutated = false
472
+ const nextTickets = prev.tickets.map((t) => {
473
+ if (t.id !== ticket.id || t.status === statusUpdate) return t
474
+ mutated = true
475
+ return { ...t, status: statusUpdate }
476
+ })
477
+ return mutated ? { ...prev, tickets: nextTickets } : prev
478
+ },
479
+ )
480
+ }
481
+
482
+ // Engagements ALWAYS need to refetch — the addendum / new
483
+ // attachment / status-change-note must land in the timeline.
484
+ // Scoped to the engagements query only; doesn't touch the
485
+ // list cache.
486
+ await queryClient.invalidateQueries({ queryKey: ['ticket-engagements'] })
376
487
  return true
377
488
  })
378
489
  } catch (err) {
@@ -393,12 +504,18 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
393
504
  )
394
505
 
395
506
  const sendMessage = useCallback(
396
- (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
507
+ async (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
397
508
  const trimmed = text.trim()
398
509
  const hasText = trimmed.length > 0
399
510
  const hasFiles = attachments.length > 0
400
- if (!hasText && !hasFiles) return Promise.resolve(false)
401
- return updateTicket(
511
+ if (!hasText && !hasFiles) return false
512
+ // Clear any stale mapped error from a prior non-sendMessage action
513
+ // (closeTicket / reopenTicket) so the post-call read only picks up
514
+ // an error THIS sendMessage produced. Without this clear, a prior
515
+ // close-failure's mapped error could leak into the banner via the
516
+ // post-call `lastUpdateErrorRef.current` read.
517
+ lastUpdateErrorRef.current = null
518
+ const ok = await updateTicket(
402
519
  ticket,
403
520
  {
404
521
  ...(hasText ? { content_addendum: trimmed } : {}),
@@ -407,8 +524,33 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
407
524
  TOAST_COPY.comment_success,
408
525
  'send message',
409
526
  )
527
+ // Banner-state coupling: SUCCESS clears any stale failure banner
528
+ // for this ticket; FAILURE populates the banner ONLY for the
529
+ // reply-specific code subset (HUBSPOT_5XX / 400 / 404 / UNKNOWN).
530
+ // Other codes (TICKET_NOT_FOUND, HUBSPOT_DISCONNECTED, RATE_LIMITED)
531
+ // are full-row / full-system signals already covered by the
532
+ // existing toast + supportSystemDown handling — surfacing them in
533
+ // the inline banner too would be redundant.
534
+ if (ok) {
535
+ clearReplyError(ticket.external_id)
536
+ } else {
537
+ // Line 466's `.current = null` narrows the property to literal
538
+ // `null`. `tsc -p tsconfig.declarations.json` (declarations
539
+ // build, distinct from the `tsc --noEmit` pre-step) doesn't
540
+ // widen that narrowing across the `await updateTicket(...)`,
541
+ // so the read here is typed `never`. The runtime type IS
542
+ // `MappedTicketActionError | null` per the useRef declaration;
543
+ // the assertion just tells TS to honor it instead of the stale
544
+ // narrowing.
545
+ const mapped = lastUpdateErrorRef.current as MappedTicketActionError | null
546
+ if (mapped && REPLY_BANNER_CODES.has(mapped.code)) {
547
+ setReplyError(ticket.external_id, mapped)
548
+ }
549
+ lastUpdateErrorRef.current = null
550
+ }
551
+ return ok
410
552
  },
411
- [updateTicket],
553
+ [updateTicket, clearReplyError, setReplyError],
412
554
  )
413
555
 
414
556
  const closeTicket = useCallback(
@@ -439,8 +581,19 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
439
581
  reopenTicket,
440
582
  isSubmittingForm,
441
583
  isRowBusy,
584
+ replyErrorFor,
585
+ clearReplyError,
442
586
  }),
443
- [submitTicket, sendMessage, closeTicket, reopenTicket, isSubmittingForm, isRowBusy],
587
+ [
588
+ submitTicket,
589
+ sendMessage,
590
+ closeTicket,
591
+ reopenTicket,
592
+ isSubmittingForm,
593
+ isRowBusy,
594
+ replyErrorFor,
595
+ clearReplyError,
596
+ ],
444
597
  )
445
598
  }
446
599
 
@@ -512,6 +665,38 @@ export function mapTicketActionError(err: unknown): MappedTicketActionError {
512
665
  supportSystemDown: false,
513
666
  removeRowFromCache: false,
514
667
  }
668
+ case 'HUBSPOT_5XX':
669
+ return {
670
+ code: err.code,
671
+ message:
672
+ "We couldn't reach the support system. Your reply wasn't sent — please retry in a moment.",
673
+ supportSystemDown: false,
674
+ removeRowFromCache: false,
675
+ }
676
+ case 'HUBSPOT_400_VALIDATION':
677
+ return {
678
+ code: err.code,
679
+ message:
680
+ 'Your reply was rejected. Please rephrase or remove unsupported content and try again.',
681
+ supportSystemDown: false,
682
+ removeRowFromCache: false,
683
+ }
684
+ case 'HUBSPOT_404_THREAD':
685
+ return {
686
+ code: err.code,
687
+ message:
688
+ 'This conversation is no longer accepting replies. Open a new ticket to continue.',
689
+ supportSystemDown: false,
690
+ removeRowFromCache: false,
691
+ }
692
+ case 'HUBSPOT_REPLY_UNKNOWN':
693
+ return {
694
+ code: err.code,
695
+ message:
696
+ "Your reply didn't go through. Please retry.",
697
+ supportSystemDown: false,
698
+ removeRowFromCache: false,
699
+ }
515
700
  default:
516
701
  return {
517
702
  code: 'UNKNOWN',
@@ -546,9 +731,20 @@ function cacheContainsTicket(
546
731
  queryClient: ReturnType<typeof useQueryClient>,
547
732
  expectedTicketId: string,
548
733
  ): boolean {
549
- const entries = queryClient.getQueriesData<TicketData[] | undefined>({ queryKey: ['tickets'] })
734
+ // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT a
735
+ // bare `TicketData[]`. The previous code's `Array.isArray(data)` guard
736
+ // silently fell through to `return false` on real responses — the
737
+ // post-create watcher therefore NEVER detected the real row arriving
738
+ // early and always waited the full timeout. Project the nested array.
739
+ const entries = queryClient.getQueriesData<TicketsCacheSlot | undefined>({
740
+ queryKey: ['tickets'],
741
+ })
550
742
  for (const [, data] of entries) {
551
- if (Array.isArray(data) && data.some((t) => t.external_id === expectedTicketId)) {
743
+ if (
744
+ data &&
745
+ Array.isArray(data.tickets) &&
746
+ data.tickets.some((t) => t.external_id === expectedTicketId)
747
+ ) {
552
748
  return true
553
749
  }
554
750
  }
@@ -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 }}
@@ -408,8 +444,15 @@ function ReopenAction({
408
444
  onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']
409
445
  }) {
410
446
  const handleReopen = async () => {
411
- const ok = await onReopen(ticketRef)
412
- if (ok) onActionCollapsed()
447
+ // Intentionally do NOT call `onActionCollapsed()` here. Pre-PR #1053
448
+ // every reopen was followed by a full list refetch which removed
449
+ // the (now-OPEN) row from a `?status=closed` view, so collapsing
450
+ // the drawer hid the disappearance flash. After #1053+#1055 the
451
+ // row stays in the list with the optimistic in-place status
452
+ // update — collapsing the drawer now actively dismisses the
453
+ // ticket the user is working on. Keep it mounted; the badge flip
454
+ // is enough feedback. (Reported 2026-05-29.)
455
+ void (await onReopen(ticketRef))
413
456
  }
414
457
  return (
415
458
  <div className="flex justify-end">
@@ -465,9 +508,14 @@ function OpenActions({
465
508
 
466
509
  const confirmClose = async () => {
467
510
  setCloseDialogOpen(false)
468
- const ok = await onClose(ticketRef, resolution.trim() || undefined)
511
+ await onClose(ticketRef, resolution.trim() || undefined)
469
512
  setResolution('')
470
- if (ok) onActionCollapsed()
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.
471
519
  }
472
520
 
473
521
  return (
@@ -562,3 +610,95 @@ function OpenActions({
562
610
  </div>
563
611
  )
564
612
  }
613
+
614
+ /**
615
+ * Persistent banner above the drawer composer/actions when the most
616
+ * recent customer reply failed with a reply-specific code (HUBSPOT_5XX
617
+ * / 400 / 404 / UNKNOWN). The transient toast already fired at the
618
+ * moment of failure; this banner stays until the next successful send
619
+ * OR the user dismisses it explicitly. Wording is sourced from
620
+ * `mapTicketActionError` so a future copy update lives in one place.
621
+ *
622
+ * 404_THREAD is the only terminal code in the set — the banner copy
623
+ * reads "open a new ticket" and Retry would just re-fail. We still
624
+ * render a Dismiss control instead of hiding Retry so the visual shape
625
+ * is uniform; the parent's composer continues to function for any
626
+ * non-thread-deletion reply path.
627
+ */
628
+ function ReplyFailureBanner({
629
+ error,
630
+ onDismiss,
631
+ }: {
632
+ error: MappedTicketActionError
633
+ onDismiss: () => void
634
+ }) {
635
+ return (
636
+ <div
637
+ role="status"
638
+ aria-live="polite"
639
+ 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"
640
+ >
641
+ <span className="font-medium leading-snug">{error.message}</span>
642
+ <Button
643
+ type="button"
644
+ variant="transparent"
645
+ onClick={onDismiss}
646
+ aria-label="Dismiss reply failure"
647
+ 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"
648
+ >
649
+ Dismiss
650
+ </Button>
651
+ </div>
652
+ )
653
+ }
654
+
655
+ /**
656
+ * Compact "Assigned to" row at the top of the drawer. Surfaces the
657
+ * support-side agent — name + avatar — so the customer knows who's
658
+ * looking at their ticket. Renders "Unassigned" when the ticket has no
659
+ * `hubspot_owner_id` OR when the owner couldn't be resolved against
660
+ * the mirror (deleted between ticket update + next reconcile).
661
+ *
662
+ * Avatar comes from the canonical `<SquareAvatar variant="round">` so
663
+ * it picks up the initials-fallback + image-proxy behavior used
664
+ * everywhere else in the lib (matches the dev-section message-bubble
665
+ * avatars). No bespoke avatar markup.
666
+ */
667
+ function AssignedAgentRow({
668
+ assignedOwner,
669
+ }: {
670
+ assignedOwner: TicketAssignedOwner | null
671
+ }) {
672
+ // Display label precedence:
673
+ // 1. `name` from the mirror (employee match OR HubSpot's first+last)
674
+ // 2. `email` local-part — covers HubSpot owners that exist but have
675
+ // no name (rare but real; the ticket IS assigned and rendering
676
+ // "Unassigned" would be misleading)
677
+ // 3. "Unassigned" — only when the ticket has no `assigned_to` OR
678
+ // the owner couldn't be resolved against the mirror at all
679
+ const trimmedName = assignedOwner?.name?.trim() || null
680
+ const emailFallback = assignedOwner?.email?.trim() || null
681
+ const displayLabel =
682
+ trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)
683
+ return (
684
+ <div className="flex items-center gap-2 text-xs">
685
+ <span className="text-ods-text-secondary uppercase tracking-wider font-medium">
686
+ Assigned to
687
+ </span>
688
+ {displayLabel ? (
689
+ <span className="flex items-center gap-1.5 text-ods-text-primary font-medium">
690
+ <SquareAvatar
691
+ size="sm"
692
+ variant="round"
693
+ src={assignedOwner?.avatarUrl ?? undefined}
694
+ alt={displayLabel}
695
+ fallback={displayLabel}
696
+ />
697
+ {displayLabel}
698
+ </span>
699
+ ) : (
700
+ <span className="text-ods-text-secondary italic">Unassigned</span>
701
+ )}
702
+ </div>
703
+ )
704
+ }
@@ -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
@@ -118,10 +139,40 @@ export function isOptimistic(t: AnyTicket): t is OptimisticTicket {
118
139
  return (t as OptimisticTicket)._optimistic === true
119
140
  }
120
141
 
142
+ /**
143
+ * Shape of a single `['tickets', …]` TanStack-Query cache slot.
144
+ * Mirrors `FindTicketResponse` in `hooks/use-tickets-list.ts` — kept
145
+ * here because the cache-mutation call sites in `useTicketActions` and
146
+ * `<HelpCenterList>` would otherwise have to redeclare the shape inline.
147
+ *
148
+ * A 2026-05-29 prod regression (`t.map is not a function` on
149
+ * close/reopen) was caused by assuming the cache held a bare
150
+ * `TicketData[]` instead of this wrapper — every helper that calls
151
+ * `queryClient.setQueriesData` / `getQueriesData` on `['tickets']`
152
+ * MUST type the value through this shape and project / reassemble
153
+ * `tickets` explicitly.
154
+ */
155
+ export interface TicketsCacheSlot {
156
+ tickets?: TicketData[]
157
+ count?: number
158
+ totalCount?: number
159
+ page?: number
160
+ pageSize?: number
161
+ totalPages?: number
162
+ scope?: 'self' | 'all'
163
+ }
164
+
121
165
  /**
122
166
  * Stable server-side error codes the ticket-action helpers route
123
167
  * through `mapTicketActionError`. Anything else is treated as a generic
124
168
  * server error.
169
+ *
170
+ * Reply-specific codes (`HUBSPOT_5XX` / `HUBSPOT_400_VALIDATION` /
171
+ * `HUBSPOT_404_THREAD` / `HUBSPOT_REPLY_UNKNOWN`) drive the drawer
172
+ * banner that appears above the composer when a customer reply fails.
173
+ * Distinct from `HUBSPOT_DISCONNECTED` (whole-system) and
174
+ * `TICKET_NOT_FOUND` (terminal-row) — the reply codes are per-attempt
175
+ * + retryable except for `HUBSPOT_404_THREAD`.
125
176
  */
126
177
  export type TicketActionErrorCode =
127
178
  | 'PROPOSAL_NOT_CLAIMABLE'
@@ -130,6 +181,10 @@ export type TicketActionErrorCode =
130
181
  | 'HUBSPOT_DISCONNECTED'
131
182
  | 'RATE_LIMITED'
132
183
  | 'INVALID_TOOL_ARGS'
184
+ | 'HUBSPOT_5XX'
185
+ | 'HUBSPOT_400_VALIDATION'
186
+ | 'HUBSPOT_404_THREAD'
187
+ | 'HUBSPOT_REPLY_UNKNOWN'
133
188
  | 'UNKNOWN'
134
189
 
135
190
  export interface MappedTicketActionError {
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Shared filter section wrapper with responsive layout.
5
+ *
6
+ * Provides the consistent card container for any filter row (status,
7
+ * platform, section, etc.) Single row on desktop with flex-wrap,
8
+ * stacks naturally on mobile.
9
+ *
10
+ * Lifted from hub `components/admin/shared/filter-section.tsx` so
11
+ * lib catalog views can render their own filter pills without an
12
+ * injected slot.
13
+ */
14
+
15
+ import { Filter } from 'lucide-react'
16
+ import { Button } from './button'
17
+
18
+ export interface FilterPillRowOption {
19
+ value: string
20
+ label: string
21
+ }
22
+
23
+ export interface FilterPillRowProps {
24
+ /** Label shown next to the filter icon, e.g. "Section", "Platform". */
25
+ label: string
26
+ /** The currently selected filter value. */
27
+ selectedValue: string
28
+ /** Callback when a filter option is selected. */
29
+ onValueChange: (value: string) => void
30
+ /** Available filter options. */
31
+ options: FilterPillRowOption[]
32
+ /** Optional count label, e.g. "Showing 1-10 of 42 items". */
33
+ countLabel?: string
34
+ /** Optional children to render instead of options (for custom filter content). */
35
+ children?: React.ReactNode
36
+ }
37
+
38
+ export function FilterPillRow({
39
+ label,
40
+ selectedValue,
41
+ onValueChange,
42
+ options,
43
+ countLabel,
44
+ children,
45
+ }: FilterPillRowProps) {
46
+ return (
47
+ <div className="flex flex-wrap items-center gap-3 p-4 bg-ods-card border border-ods-border rounded-lg">
48
+ <div className="flex items-center gap-2">
49
+ <Filter className="h-4 w-4 text-ods-accent" />
50
+ <span className="text-h5 text-ods-text-secondary">{label}</span>
51
+ </div>
52
+ {children ||
53
+ options.map((opt) => (
54
+ <Button
55
+ key={opt.value}
56
+ type="button"
57
+ variant={selectedValue === opt.value ? 'accent' : 'outline'}
58
+ size="small-legacy"
59
+ onClick={() => onValueChange(opt.value)}
60
+ className="text-h3"
61
+ >
62
+ {opt.label}
63
+ </Button>
64
+ ))}
65
+ {countLabel && (
66
+ <div className="ml-auto text-[12px] font-['DM_Sans'] text-ods-text-secondary shrink-0">
67
+ {countLabel}
68
+ </div>
69
+ )}
70
+ </div>
71
+ )
72
+ }
@@ -154,4 +154,5 @@ export * from './ticket-attachments-list'
154
154
  export * from './ticket-note-card'
155
155
  export * from './ticket-notes-section'
156
156
  export * from './simple-markdown-renderer'
157
+ export * from './filter-pill-row'
157
158