@flamingo-stack/openframe-frontend-core 0.0.503 → 0.0.504-snapshot.20260801181709

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 (191) hide show
  1. package/dist/{chunk-SGWPW2AO.cjs → chunk-2A74VRAF.cjs} +18 -18
  2. package/dist/{chunk-SGWPW2AO.cjs.map → chunk-2A74VRAF.cjs.map} +1 -1
  3. package/dist/{chunk-LILQJ3UZ.cjs → chunk-3ENGUBJP.cjs} +1 -1
  4. package/dist/chunk-3ENGUBJP.cjs.map +1 -0
  5. package/dist/{chunk-AWDHRIL3.cjs → chunk-4ST2BOWJ.cjs} +75 -75
  6. package/dist/{chunk-AWDHRIL3.cjs.map → chunk-4ST2BOWJ.cjs.map} +1 -1
  7. package/dist/{chunk-SFJRMBJC.js → chunk-5SVNA7IU.js} +2 -2
  8. package/dist/{chunk-OAWFYZZT.js → chunk-7DOSAJDK.js} +3 -3
  9. package/dist/{chunk-J4T5ZBWG.cjs → chunk-BZRP54IO.cjs} +19 -19
  10. package/dist/{chunk-J4T5ZBWG.cjs.map → chunk-BZRP54IO.cjs.map} +1 -1
  11. package/dist/{chunk-BZF63JXP.js → chunk-CRJVO4KO.js} +6 -6
  12. package/dist/{chunk-5RIHLXEP.cjs → chunk-DDVQ5NKK.cjs} +33 -33
  13. package/dist/{chunk-5RIHLXEP.cjs.map → chunk-DDVQ5NKK.cjs.map} +1 -1
  14. package/dist/{chunk-HR6F6IP4.js → chunk-DIKJLAMY.js} +78 -43
  15. package/dist/chunk-DIKJLAMY.js.map +1 -0
  16. package/dist/{chunk-VO2ZONMR.js → chunk-FNTFWP2A.js} +3 -3
  17. package/dist/{chunk-2OXJ6NHI.cjs → chunk-GJZ42TTG.cjs} +7 -7
  18. package/dist/{chunk-2OXJ6NHI.cjs.map → chunk-GJZ42TTG.cjs.map} +1 -1
  19. package/dist/{chunk-JIZJDAXI.cjs → chunk-GXRVBKYX.cjs} +12 -12
  20. package/dist/{chunk-JIZJDAXI.cjs.map → chunk-GXRVBKYX.cjs.map} +1 -1
  21. package/dist/{chunk-F7AD5CUU.js → chunk-H3N4P3CG.js} +3 -3
  22. package/dist/chunk-H3N4P3CG.js.map +1 -0
  23. package/dist/{chunk-VCPTU4IW.js → chunk-HWER6KGU.js} +7 -7
  24. package/dist/{chunk-4SR2QHMB.js → chunk-ITVWX5HX.js} +3 -3
  25. package/dist/{chunk-FFXNRJ5S.cjs → chunk-LOBR7IC6.cjs} +6 -6
  26. package/dist/chunk-LOBR7IC6.cjs.map +1 -0
  27. package/dist/{chunk-72DF3QTA.cjs → chunk-MJQ263KS.cjs} +14 -14
  28. package/dist/{chunk-72DF3QTA.cjs.map → chunk-MJQ263KS.cjs.map} +1 -1
  29. package/dist/{chunk-FTOVZ7O2.js → chunk-NPO6CHCR.js} +7 -7
  30. package/dist/{chunk-XV5SXALY.js → chunk-NT5FX7GZ.js} +3 -3
  31. package/dist/{chunk-UCV4LFUG.cjs → chunk-NY75FKN6.cjs} +40 -40
  32. package/dist/{chunk-UCV4LFUG.cjs.map → chunk-NY75FKN6.cjs.map} +1 -1
  33. package/dist/{chunk-4UNQMYP7.cjs → chunk-O32Y4E4R.cjs} +3 -3
  34. package/dist/{chunk-4UNQMYP7.cjs.map → chunk-O32Y4E4R.cjs.map} +1 -1
  35. package/dist/{chunk-CLQEBWV7.js → chunk-QE55ROZW.js} +4 -4
  36. package/dist/{chunk-WHJGRRK2.js → chunk-QW7SW5HY.js} +52 -43
  37. package/dist/chunk-QW7SW5HY.js.map +1 -0
  38. package/dist/{chunk-JGXAVNZQ.js → chunk-R6CKL7IR.js} +2464 -1886
  39. package/dist/chunk-R6CKL7IR.js.map +1 -0
  40. package/dist/{chunk-L5CI57RM.cjs → chunk-T3BF6JTQ.cjs} +52 -43
  41. package/dist/chunk-T3BF6JTQ.cjs.map +1 -0
  42. package/dist/{chunk-G43CEISB.js → chunk-TA3VJ2QO.js} +4 -4
  43. package/dist/{chunk-7L2RVINX.cjs → chunk-TRS4CIAS.cjs} +35 -35
  44. package/dist/{chunk-7L2RVINX.cjs.map → chunk-TRS4CIAS.cjs.map} +1 -1
  45. package/dist/{chunk-DMXB3VE2.cjs → chunk-U3YLML53.cjs} +1837 -1259
  46. package/dist/chunk-U3YLML53.cjs.map +1 -0
  47. package/dist/{chunk-3T4SMJM5.cjs → chunk-VDKNUC3N.cjs} +181 -146
  48. package/dist/chunk-VDKNUC3N.cjs.map +1 -0
  49. package/dist/{chunk-RR3Z52X5.cjs → chunk-W3QCFQ56.cjs} +42 -42
  50. package/dist/{chunk-RR3Z52X5.cjs.map → chunk-W3QCFQ56.cjs.map} +1 -1
  51. package/dist/{chunk-VYEDTVIZ.js → chunk-XJPMTJAR.js} +4 -4
  52. package/dist/{chunk-6GT557KN.js → chunk-XKIA4XVE.js} +1 -1
  53. package/dist/chunk-XKIA4XVE.js.map +1 -0
  54. package/dist/components/case-studies/index.cjs +11 -11
  55. package/dist/components/case-studies/index.js +5 -5
  56. package/dist/components/chat/index.cjs +5 -5
  57. package/dist/components/chat/index.js +4 -4
  58. package/dist/components/contact/index.cjs +6 -6
  59. package/dist/components/contact/index.js +5 -5
  60. package/dist/components/docs/index.cjs +8 -8
  61. package/dist/components/docs/index.js +7 -7
  62. package/dist/components/embeds/index.cjs +6 -6
  63. package/dist/components/embeds/index.js +5 -5
  64. package/dist/components/faq/index.cjs +7 -7
  65. package/dist/components/faq/index.js +6 -6
  66. package/dist/components/features/index.cjs +5 -5
  67. package/dist/components/features/index.js +4 -4
  68. package/dist/components/help-center-pages/index.cjs +24 -24
  69. package/dist/components/help-center-pages/index.js +15 -15
  70. package/dist/components/index.cjs +169 -165
  71. package/dist/components/index.cjs.map +1 -1
  72. package/dist/components/index.js +17 -13
  73. package/dist/components/index.js.map +1 -1
  74. package/dist/components/navigation/app-header.d.ts +8 -0
  75. package/dist/components/navigation/app-header.d.ts.map +1 -1
  76. package/dist/components/navigation/header-button.d.ts +5 -1
  77. package/dist/components/navigation/header-button.d.ts.map +1 -1
  78. package/dist/components/navigation/header.d.ts.map +1 -1
  79. package/dist/components/navigation/index.cjs +9 -5
  80. package/dist/components/navigation/index.cjs.map +1 -1
  81. package/dist/components/navigation/index.d.ts +4 -0
  82. package/dist/components/navigation/index.d.ts.map +1 -1
  83. package/dist/components/navigation/index.js +8 -4
  84. package/dist/components/navigation/mingo-ai-button.d.ts.map +1 -1
  85. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  86. package/dist/components/navigation/ticket-alerts-button.d.ts +29 -0
  87. package/dist/components/navigation/ticket-alerts-button.d.ts.map +1 -0
  88. package/dist/components/navigation/top-navigation.d.ts +5 -1
  89. package/dist/components/navigation/top-navigation.d.ts.map +1 -1
  90. package/dist/components/navigation/unread-dot.d.ts +19 -0
  91. package/dist/components/navigation/unread-dot.d.ts.map +1 -0
  92. package/dist/components/onboarding-guides/index.cjs +9 -9
  93. package/dist/components/onboarding-guides/index.js +8 -8
  94. package/dist/components/related-content/index.cjs +7 -7
  95. package/dist/components/related-content/index.js +6 -6
  96. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  97. package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
  98. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +1 -9
  99. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  100. package/dist/components/tickets/hooks/use-tickets-list.d.ts +0 -7
  101. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  102. package/dist/components/tickets/index.cjs +15 -8
  103. package/dist/components/tickets/index.cjs.map +1 -1
  104. package/dist/components/tickets/index.d.ts +2 -1
  105. package/dist/components/tickets/index.d.ts.map +1 -1
  106. package/dist/components/tickets/index.js +14 -7
  107. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  108. package/dist/components/tickets/ticket-live-provider.d.ts +58 -0
  109. package/dist/components/tickets/ticket-live-provider.d.ts.map +1 -0
  110. package/dist/components/tickets/types.d.ts +58 -11
  111. package/dist/components/tickets/types.d.ts.map +1 -1
  112. package/dist/components/ui/button/button.d.ts +6 -5
  113. package/dist/components/ui/button/button.d.ts.map +1 -1
  114. package/dist/components/ui/file-manager/index.cjs +50 -50
  115. package/dist/components/ui/file-manager/index.js +1 -1
  116. package/dist/components/ui/index.cjs +5 -5
  117. package/dist/components/ui/index.js +4 -4
  118. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  119. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  120. package/dist/contexts/index.cjs +2 -2
  121. package/dist/contexts/index.js +1 -1
  122. package/dist/hooks/index.cjs +3 -3
  123. package/dist/hooks/index.js +2 -2
  124. package/dist/hooks/ui/use-header-height.d.ts.map +1 -1
  125. package/dist/index.cjs +11 -5
  126. package/dist/index.cjs.map +1 -1
  127. package/dist/index.js +10 -4
  128. package/dist/types/navigation.d.ts +10 -0
  129. package/dist/types/navigation.d.ts.map +1 -1
  130. package/dist/utils/index.cjs +171 -0
  131. package/dist/utils/index.cjs.map +1 -1
  132. package/dist/utils/index.d.ts +1 -0
  133. package/dist/utils/index.d.ts.map +1 -1
  134. package/dist/utils/index.js +171 -1
  135. package/dist/utils/index.js.map +1 -1
  136. package/dist/utils/sse-subscription.d.ts +60 -0
  137. package/dist/utils/sse-subscription.d.ts.map +1 -0
  138. package/package.json +1 -1
  139. package/src/components/features/time-tracker/time-tracker-header-button.tsx +1 -1
  140. package/src/components/navigation/app-header.tsx +36 -20
  141. package/src/components/navigation/header-button.tsx +18 -2
  142. package/src/components/navigation/header-mingo-button.tsx +2 -2
  143. package/src/components/navigation/header-skeleton.tsx +4 -4
  144. package/src/components/navigation/header.tsx +41 -21
  145. package/src/components/navigation/index.ts +4 -0
  146. package/src/components/navigation/mingo-ai-button.tsx +23 -22
  147. package/src/components/navigation/mobile-burger-menu.tsx +3 -3
  148. package/src/components/navigation/navigation-sidebar-item.tsx +2 -3
  149. package/src/components/navigation/ticket-alerts-button.tsx +61 -0
  150. package/src/components/navigation/top-navigation.tsx +17 -1
  151. package/src/components/navigation/unread-dot.tsx +34 -0
  152. package/src/components/tickets/.types.md +1 -2
  153. package/src/components/tickets/help-center-card.tsx +16 -0
  154. package/src/components/tickets/help-center-list.tsx +6 -6
  155. package/src/components/tickets/hooks/.use-ticket-engagements.md +3 -12
  156. package/src/components/tickets/hooks/.use-tickets-list.md +0 -2
  157. package/src/components/tickets/hooks/use-ticket-actions.ts +9 -0
  158. package/src/components/tickets/hooks/use-ticket-engagements.ts +4 -13
  159. package/src/components/tickets/hooks/use-tickets-list.ts +2 -13
  160. package/src/components/tickets/index.ts +10 -0
  161. package/src/components/tickets/ticket-detail-drawer.tsx +45 -13
  162. package/src/components/tickets/ticket-live-provider.tsx +474 -0
  163. package/src/components/tickets/types.ts +62 -11
  164. package/src/components/ui/button/button.tsx +165 -133
  165. package/src/contexts/chat-runtime-context.tsx +8 -0
  166. package/src/hooks/ui/use-header-height.ts +3 -1
  167. package/src/stories/TopNavigation.stories.tsx +20 -1
  168. package/src/types/navigation.ts +10 -0
  169. package/src/utils/index.ts +12 -0
  170. package/src/utils/sse-subscription.ts +276 -0
  171. package/dist/chunk-3T4SMJM5.cjs.map +0 -1
  172. package/dist/chunk-6GT557KN.js.map +0 -1
  173. package/dist/chunk-DMXB3VE2.cjs.map +0 -1
  174. package/dist/chunk-F7AD5CUU.js.map +0 -1
  175. package/dist/chunk-FFXNRJ5S.cjs.map +0 -1
  176. package/dist/chunk-HR6F6IP4.js.map +0 -1
  177. package/dist/chunk-JGXAVNZQ.js.map +0 -1
  178. package/dist/chunk-L5CI57RM.cjs.map +0 -1
  179. package/dist/chunk-LILQJ3UZ.cjs.map +0 -1
  180. package/dist/chunk-WHJGRRK2.js.map +0 -1
  181. /package/dist/{chunk-SFJRMBJC.js.map → chunk-5SVNA7IU.js.map} +0 -0
  182. /package/dist/{chunk-OAWFYZZT.js.map → chunk-7DOSAJDK.js.map} +0 -0
  183. /package/dist/{chunk-BZF63JXP.js.map → chunk-CRJVO4KO.js.map} +0 -0
  184. /package/dist/{chunk-VO2ZONMR.js.map → chunk-FNTFWP2A.js.map} +0 -0
  185. /package/dist/{chunk-VCPTU4IW.js.map → chunk-HWER6KGU.js.map} +0 -0
  186. /package/dist/{chunk-4SR2QHMB.js.map → chunk-ITVWX5HX.js.map} +0 -0
  187. /package/dist/{chunk-FTOVZ7O2.js.map → chunk-NPO6CHCR.js.map} +0 -0
  188. /package/dist/{chunk-XV5SXALY.js.map → chunk-NT5FX7GZ.js.map} +0 -0
  189. /package/dist/{chunk-CLQEBWV7.js.map → chunk-QE55ROZW.js.map} +0 -0
  190. /package/dist/{chunk-G43CEISB.js.map → chunk-TA3VJ2QO.js.map} +0 -0
  191. /package/dist/{chunk-VYEDTVIZ.js.map → chunk-XJPMTJAR.js.map} +0 -0
@@ -5,6 +5,17 @@ import { cn } from '../../utils/cn';
5
5
 
6
6
  export type TopNavigationCenterBreakpoint = 'md' | 'lg';
7
7
 
8
+ export type TopNavigationSize = 'small' | 'big';
9
+
10
+ // Bar height + square-cell width per size (Figma 2797-5978 `size` axis).
11
+ // Heights are FIXED across breakpoints. `--top-nav-cell` is consumed by
12
+ // `HeaderButton` (and any custom cell) so cell widths follow the size without
13
+ // each cell knowing about it.
14
+ const SIZE_CLASSES: Record<TopNavigationSize, string> = {
15
+ small: 'h-14 [--top-nav-cell:3.5rem]',
16
+ big: 'h-[72px] [--top-nav-cell:4.5rem]',
17
+ };
18
+
8
19
  // Literal class maps — Tailwind's scanner needs the full class strings.
9
20
  const CENTER_VISIBILITY: Record<TopNavigationCenterBreakpoint, string> = {
10
21
  md: 'hidden md:flex',
@@ -52,6 +63,9 @@ export interface TopNavigationProps extends React.HTMLAttributes<HTMLElement> {
52
63
  backgroundClassName?: string;
53
64
  /** Top border on mobile (the ODS spec bar shows it below md). Default true. */
54
65
  mobileTopBorder?: boolean;
66
+ /** Bar size (Figma 2797-5978 `size` axis): `small` = 56px (console),
67
+ * `big` = 72px (marketing/hub sites). Fixed across breakpoints. */
68
+ size?: TopNavigationSize;
55
69
  }
56
70
 
57
71
  const hasContent = (node: React.ReactNode): boolean => node !== null && node !== undefined && node !== false;
@@ -80,6 +94,7 @@ export function TopNavigation({
80
94
  sideActions,
81
95
  backgroundClassName,
82
96
  mobileTopBorder = true,
97
+ size = 'small',
83
98
  className,
84
99
  children,
85
100
  ...props
@@ -87,7 +102,8 @@ export function TopNavigation({
87
102
  return (
88
103
  <header
89
104
  className={cn(
90
- 'flex h-12 w-full items-center border-b border-ods-border md:h-14',
105
+ 'flex w-full items-center border-b border-ods-border',
106
+ SIZE_CLASSES[size],
91
107
  mobileTopBorder && 'border-t md:border-t-0',
92
108
  backgroundClassName ?? 'bg-ods-card',
93
109
  className,
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { cn } from '../../utils/cn'
5
+
6
+ /**
7
+ * The ONE unread-indicator dot (`bg-ods-warning`, top-right of an icon
8
+ * inside a `relative` wrapper). Previously duplicated in
9
+ * `NotificationsHeaderButton` and `NavigationSidebarItemButton` — both
10
+ * now render THIS, as does `TicketAlertsButton`.
11
+ *
12
+ * Size variants preserve the two pre-existing renders exactly (neither
13
+ * may silently change):
14
+ * - `responsive` — header cells: `w-1.5 h-1.5 md:w-2 md:h-2`
15
+ * - `fixed` — sidebar collapsed icon: `w-2 h-2`
16
+ */
17
+ export interface UnreadDotProps {
18
+ size?: 'responsive' | 'fixed'
19
+ className?: string
20
+ }
21
+
22
+ export function UnreadDot({ size = 'responsive', className }: UnreadDotProps) {
23
+ return (
24
+ <span
25
+ className={cn(
26
+ 'absolute top-0 right-0 bg-ods-warning rounded-full',
27
+ size === 'responsive' ? 'w-1.5 h-1.5 md:w-2 md:h-2' : 'w-2 h-2',
28
+ className,
29
+ )}
30
+ />
31
+ )
32
+ }
33
+
34
+ export default UnreadDot
@@ -28,7 +28,7 @@ Shared type contracts, constants, and utilities for the OpenFrame ticket system
28
28
  | Name | Value | Purpose |
29
29
  |---|---|---|
30
30
  | `TICKET_TEXT_MAX_CHARS` | `5000` | Client-side cap on ticket/comment text length |
31
- | `TICKET_LIVE_POLL_MS` | `8000` | Shared refetch interval (ms) for list + conversation polls on open drawers |
31
+ | `TICKET_MARK_READ_DEBOUNCE_MS` | `2000` | Trailing debounce for drawer mark-read while new messages stream in |
32
32
  | `TOAST_COPY` | `as const` object | Centralized, QA-auditable toast strings for all ticket actions |
33
33
 
34
34
  ## Usage Example
@@ -38,7 +38,6 @@ import {
38
38
  AnyTicket,
39
39
  isOptimistic,
40
40
  TicketsCacheSlot,
41
- TICKET_LIVE_POLL_MS,
42
41
  TICKET_TEXT_MAX_CHARS,
43
42
  TOAST_COPY,
44
43
  } from '@openframe/oss-lib'
@@ -26,6 +26,7 @@ import {
26
26
  TicketDetailDrawer,
27
27
  type TicketDetailDrawerProps,
28
28
  } from './ticket-detail-drawer'
29
+ import { useOptionalTicketLive } from './ticket-live-provider'
29
30
  import type { AnyTicket } from './types'
30
31
  import { isOptimistic } from './types'
31
32
 
@@ -118,8 +119,23 @@ export function HelpCenterCard({
118
119
  return () => cancelAnimationFrame(raf)
119
120
  }, [isExpanded])
120
121
 
122
+ // Unread replies for this row — read from the provider's single summary
123
+ // map (missing key / no provider = 0). The provider masks the open
124
+ // ticket to 0 and zeroes on markRead, so no local state here.
125
+ const live = useOptionalTicketLive()
126
+ const unreadCount =
127
+ (!optimistic && ticket.external_id && live?.unreadByTicket[ticket.external_id]) || 0
128
+
121
129
  const rightBadges = (
122
130
  <>
131
+ {unreadCount > 0 && (
132
+ <StatusBadge
133
+ text={`${unreadCount} NEW`}
134
+ colorScheme="warning"
135
+ variant="card"
136
+ className="border border-ods-border"
137
+ />
138
+ )}
123
139
  <StatusBadge
124
140
  text={rawStatus}
125
141
  colorScheme={getStatusColorScheme(rawStatus)}
@@ -44,7 +44,7 @@ import { useTicketActions } from './hooks/use-ticket-actions'
44
44
  import { HelpCenterCard } from './help-center-card'
45
45
  import { HelpCenterCreateForm, HelpCenterCreateFormSkeleton } from './help-center-create-form'
46
46
  import type { AnyTicket, OptimisticTicket, TicketsCacheSlot } from './types'
47
- import { isOptimistic, TICKET_LIVE_POLL_MS } from './types'
47
+ import { isOptimistic } from './types'
48
48
 
49
49
  export interface HelpCenterListProps {
50
50
  /** Toast override (test-friendly). Defaults to the lib's shared
@@ -212,11 +212,11 @@ function HelpCenterListAuthed({
212
212
  search,
213
213
  status,
214
214
  page,
215
- // Live status: while a drawer is open, poll so an out-of-band HubSpot
216
- // status change (e.g. agent closes the ticket) flips the badge +
217
- // open/reopen affordance within one interval. Idle (no drawer) → no poll.
218
- // `ticketParam` (the open ticket's external_id) is the open signal.
219
- refetchInterval: ticketParam ? TICKET_LIVE_POLL_MS : false,
215
+ // No interval polling (deleted 2026-08): `TicketLiveProvider`
216
+ // invalidates `['tickets']` on stream events; focus/mount refetch
217
+ // covers hosts without a stream. Disclosed trade-off: a bare
218
+ // status/pipeline change with no accompanying reply has no live
219
+ // path until the next event/focus/reconnect.
220
220
  })
221
221
 
222
222
  // Open state DERIVED from the URL param. `?ticket=` carries the user-facing
@@ -11,28 +11,19 @@ Fetches and manages the conversation timeline (Note engagements and attachments)
11
11
 
12
12
  ### Hook
13
13
 
14
- **`useTicketEngagements(externalTicketId, enabled?, refetchInterval?)`**
14
+ **`useTicketEngagements(externalTicketId, enabled?)`**
15
15
 
16
16
  - Skips fetching for optimistic (`temp-*`) ticket IDs and unauthenticated (`anon`) identities
17
17
  - Posts to `/api/chat/agent/list-engagements` via `embedAuthedFetch`
18
18
  - Disables all caching (`staleTime: 0`, `gcTime: 0`) to ensure HubSpot truth on every drawer open
19
- - Supports configurable live-polling via `refetchInterval`; polling automatically stops when the drawer unmounts
19
+ - No interval polling — live refresh is push-driven (`TicketLiveProvider` invalidates `['ticket-engagements', id]` on stream events); focus/mount refetch is the no-stream fallback
20
20
  - Fixes a skeleton double-flash by treating a fetchable ticket as "loading" from the very first render, even before `useChatIdentity` resolves
21
21
 
22
22
  ## Usage Example
23
23
 
24
24
  ```typescript
25
- // Basic usage — fetch once when drawer opens
25
+ // Fetch when the drawer opens; live updates arrive via TicketLiveProvider
26
26
  const { engagements, isLoading, error } = useTicketEngagements(ticket.externalId)
27
-
28
- // Live-polling variant — refresh every 15 seconds while drawer is open
29
- const TICKET_LIVE_POLL_MS = 15_000
30
-
31
- const { engagements, isFetching, refetch } = useTicketEngagements(
32
- ticket.externalId,
33
- true,
34
- TICKET_LIVE_POLL_MS,
35
- )
36
27
  ```
37
28
 
38
29
  ## Notes
@@ -9,7 +9,6 @@ Input interface controlling the query:
9
9
  - `search` — free-text FTS filter; empty string returns all tickets
10
10
  - `status` — `'open'` | `'closed'` | `'all'` (empty/`'all'` omits the filter)
11
11
  - `page` / `pageSize` — 1-based pagination; server caps `pageSize` at 100
12
- - `refetchInterval` — optional polling interval (ms) for live status updates; paused on hidden tabs
13
12
 
14
13
  ### `UseTicketsListReturn`
15
14
  Output shape:
@@ -41,7 +40,6 @@ const {
41
40
  status: 'open',
42
41
  page: currentPage,
43
42
  pageSize: 20,
44
- refetchInterval: isDrawerOpen ? 10_000 : false,
45
43
  })
46
44
  ```
47
45
 
@@ -33,6 +33,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
33
  import { useQueryClient } from '@tanstack/react-query'
34
34
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
35
35
  import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
36
+ import { useOptionalTicketLive } from '../ticket-live-provider'
36
37
  import type { ChatAttachment } from '../../chat/utils/chat-attachment-markdown'
37
38
  import {
38
39
  type AnyTicket,
@@ -154,6 +155,10 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
154
155
  // the bare hub path when unconfigured.
155
156
  const ticketActionEndpoint =
156
157
  useRequiredChatRuntime().endpoints.ticketActionUrl ?? TICKET_ACTION_ENDPOINT
158
+ // Optional live-stream context: a first-ever ticket must retry the
159
+ // provider's `no-stream` (204) subscription so replies stream in.
160
+ const ticketLive = useOptionalTicketLive()
161
+ const notifyTicketCreated = ticketLive?.notifyTicketCreated
157
162
  const { prependOptimistic, removeOptimistic, removeTicketFromCache, toast, onSupportSystemDown } = options
158
163
 
159
164
  // Form-level single-flight uses BOTH a ref (for synchronous guarding
@@ -390,6 +395,9 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
390
395
  await queryClient.invalidateQueries({ queryKey: ['tickets'] })
391
396
  removeOptimistic(placeholderId)
392
397
  }
398
+ // A zero-ticket viewer's stream returned 204 before this
399
+ // create — nudge the provider to (re)subscribe.
400
+ notifyTicketCreated?.()
393
401
  return true
394
402
  })
395
403
  } catch (err) {
@@ -409,6 +417,7 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
409
417
  queryClient,
410
418
  toast,
411
419
  watchMirrorSync,
420
+ notifyTicketCreated,
412
421
  surfaceError,
413
422
  ],
414
423
  )
@@ -77,14 +77,6 @@ export interface UseTicketEngagementsReturn {
77
77
  export function useTicketEngagements(
78
78
  externalTicketId: string | null | undefined,
79
79
  enabled = true,
80
- /** Poll cadence (ms) for live conversation refresh while the drawer is
81
- * open. The drawer only mounts this hook when expanded, so a constant
82
- * here is already gated to "drawer open" — closing the drawer unmounts
83
- * the panel and the polling stops. `false`/undefined disables it (the
84
- * default, preserving prior fetch-once-per-open behavior for any other
85
- * caller). Mirrors `useTicketsList.refetchInterval`; see
86
- * `TICKET_LIVE_POLL_MS`. */
87
- refetchInterval: number | false = false,
88
80
  ): UseTicketEngagementsReturn {
89
81
  const identity = useChatIdentity()
90
82
  const identityKey = identity.user?.email ?? 'anon'
@@ -115,11 +107,10 @@ export function useTicketEngagements(
115
107
  gcTime: 0,
116
108
  refetchOnMount: 'always',
117
109
  refetchOnWindowFocus: true,
118
- // Live conversation: poll while the caller opts in (drawer open). New
119
- // agent replies + attachments appear within one interval without a
120
- // manual refresh. `refetchIntervalInBackground` stays false (default)
121
- // so polling pauses on a hidden tab.
122
- refetchInterval,
110
+ // NO interval polling (deleted 2026-08): new agent replies arrive via
111
+ // `TicketLiveProvider`'s stream-driven invalidation of
112
+ // `['ticket-engagements', id]`; focus/mount refetch is the no-stream
113
+ // fallback.
123
114
  queryFn: async (): Promise<TicketEngagement[]> => {
124
115
  const response = await embedAuthedFetch(listEngagementsEndpoint, {
125
116
  method: 'POST',
@@ -59,13 +59,6 @@ export interface UseTicketsListFilters {
59
59
  page?: number
60
60
  /** Items per page (server caps at 100). Defaults to 20. */
61
61
  pageSize?: number
62
- /** Poll interval (ms) for live updates — e.g. so a ticket CLOSED out-of-band
63
- * on HubSpot flips the status badge + open/reopen affordance without a manual
64
- * refresh. `false`/0/undefined disables polling. The hub passes a value only
65
- * while a drawer is open (mirror webhooks keep the server fresh; this surfaces
66
- * it client-side). TanStack pauses interval polling while the tab is hidden,
67
- * so there are no wasted background requests. */
68
- refetchInterval?: number | false
69
62
  }
70
63
 
71
64
  export interface UseTicketsListReturn {
@@ -116,8 +109,6 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
116
109
  // previous identity's data.
117
110
  const identityKey = customerEmail || 'anon'
118
111
 
119
- const refetchInterval = filters.refetchInterval ?? false
120
-
121
112
  const query = useQuery({
122
113
  queryKey: ['tickets', 'self', identityKey, search, statusFilter, page, pageSize],
123
114
  enabled,
@@ -130,10 +121,8 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
130
121
  gcTime: 0,
131
122
  refetchOnMount: 'always',
132
123
  refetchOnWindowFocus: true,
133
- // Live status: poll while the caller opts in (drawer open). Defaults to
134
- // false. `refetchIntervalInBackground` stays false (the default) so polling
135
- // pauses on a hidden tab — no wasted requests when the user tabs away.
136
- refetchInterval,
124
+ // NO interval polling (deleted 2026-08): live freshness is push-driven
125
+ // — `TicketLiveProvider` invalidates `['tickets']` on stream events.
137
126
  queryFn: async (): Promise<FindTicketResponse> => {
138
127
  const body: Record<string, string | number> = {
139
128
  query: search,
@@ -38,6 +38,12 @@ export {
38
38
  type TicketEngagement,
39
39
  type TicketEngagementFile,
40
40
  } from './hooks/use-ticket-engagements'
41
+ export {
42
+ TicketLiveProvider,
43
+ useTicketLive,
44
+ useOptionalTicketLive,
45
+ type TicketLiveContextValue,
46
+ } from './ticket-live-provider'
41
47
  export {
42
48
  type TicketData,
43
49
  type TicketClickupSummary,
@@ -45,6 +51,10 @@ export {
45
51
  type AnyTicket,
46
52
  type TicketActionErrorCode,
47
53
  type MappedTicketActionError,
54
+ type TicketStreamEvent,
55
+ type TicketStreamEventType,
56
+ type TicketMessageStreamData,
57
+ type TicketUnreadSummary,
48
58
  TOAST_COPY as TICKET_TOAST_COPY,
49
59
  isOptimistic,
50
60
  } from './types'
@@ -21,6 +21,7 @@
21
21
  * callbacks; we don't reach into the QueryClient.
22
22
  */
23
23
 
24
+ import { useEffect, useRef } from 'react'
24
25
  import { useStickToBottom } from 'use-stick-to-bottom'
25
26
  import { Button } from './../ui/button'
26
27
  import { useChatIdentity } from './../chat/hooks/use-chat-identity'
@@ -40,13 +41,14 @@ import type {
40
41
  TicketEngagementFile,
41
42
  } from './hooks/use-ticket-engagements'
42
43
  import { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card'
44
+ import { useOptionalTicketLive } from './ticket-live-provider'
43
45
  import { TicketReplyComposer } from './ticket-reply-composer'
44
46
  import type {
45
47
  AnyTicket,
46
48
  TicketAssignedOwner,
47
49
  MappedTicketActionError,
48
50
  } from './types'
49
- import { isOptimistic, TICKET_LIVE_POLL_MS } from './types'
51
+ import { isOptimistic, TICKET_MARK_READ_DEBOUNCE_MS } from './types'
50
52
 
51
53
  /** Identity bundle threaded through the action callbacks: local mirror
52
54
  * UUID + HubSpot external_id. Actions send `external_id` to HubSpot
@@ -204,18 +206,48 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
204
206
  // Optimistic placeholders don't have a real external_id yet — skip
205
207
  // the engagement fetch until the real ticket lands.
206
208
  const externalId = isOptimistic(ticket) ? null : ticket.external_id
207
- // Live conversation refresh: this panel only mounts while the drawer is
208
- // open, so the constant interval is already gated to "open" (closing the
209
- // drawer unmounts the panel → polling stops). New agent replies +
210
- // attachments surface within one cadence without a manual refresh — the
211
- // same 8s the list-level status/assignee poll uses (single source:
212
- // TICKET_LIVE_POLL_MS). A background poll never flashes the skeleton
213
- // (the `isLoading` guard below keys off "no data yet", not `isFetching`).
214
- const { engagements, isLoading } = useTicketEngagements(
215
- externalId,
216
- !!externalId,
217
- TICKET_LIVE_POLL_MS,
218
- )
209
+ // Live conversation refresh is PUSH-driven: `TicketLiveProvider`
210
+ // invalidates `['ticket-engagements', id]` on stream events, so new
211
+ // agent replies surface without any interval (polling was deleted
212
+ // 2026-08; `refetchOnWindowFocus` remains the no-stream fallback).
213
+ const { engagements, isLoading } = useTicketEngagements(externalId, !!externalId)
214
+
215
+ // Read-state lifecycle — this panel mounts exactly while the drawer is
216
+ // open, so it owns: register the open ticket (provider suppresses
217
+ // unread bumps + masks the row), markRead on open, debounced markRead
218
+ // as new messages stream in, flush on close. The provider's callbacks
219
+ // are stable (useCallback) so these effects don't re-fire on unrelated
220
+ // context-value changes.
221
+ const live = useOptionalTicketLive()
222
+ const setOpenTicketId = live?.setOpenTicketId
223
+ const markRead = live?.markRead
224
+ const markReadDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
225
+
226
+ useEffect(() => {
227
+ if (!externalId || !setOpenTicketId || !markRead) return
228
+ setOpenTicketId(externalId)
229
+ void markRead(externalId)
230
+ return () => {
231
+ // Flush a pending debounced markRead on close so a reply that
232
+ // arrived moments before closing is still receipted.
233
+ if (markReadDebounceRef.current) {
234
+ clearTimeout(markReadDebounceRef.current)
235
+ markReadDebounceRef.current = null
236
+ void markRead(externalId)
237
+ }
238
+ setOpenTicketId(null)
239
+ }
240
+ }, [externalId, setOpenTicketId, markRead])
241
+
242
+ const engagementsCount = engagements.length
243
+ useEffect(() => {
244
+ if (!externalId || !markRead || engagementsCount === 0) return
245
+ if (markReadDebounceRef.current) clearTimeout(markReadDebounceRef.current)
246
+ markReadDebounceRef.current = setTimeout(() => {
247
+ markReadDebounceRef.current = null
248
+ void markRead(externalId)
249
+ }, TICKET_MARK_READ_DEBOUNCE_MS)
250
+ }, [engagementsCount, externalId, markRead])
219
251
 
220
252
  // Slack-style auto-tail (same lib mechanism `ChatMessageList` uses): jump to
221
253
  // the newest message on open (`initial:'instant'`), smooth-scroll on a new