@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
@@ -0,0 +1,474 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * TicketLiveProvider — the ONE client home for support-ticket realtime.
5
+ *
6
+ * Owns:
7
+ * - the SSE subscription lifecycle (`createSseSubscription` over
8
+ * `ticketStreamUrl`), with `connected` derived from the SERVER's
9
+ * `status` frames (`subscribed` → true; `retrying`/`reconnect_failed`
10
+ * → false) — never from "the HTTP stream is open";
11
+ * - the unread summary (single source: `ticket-unread-summary`) —
12
+ * header badge total AND per-row dots both read this map;
13
+ * - resync on every (re)connect (summary refetch + engagements
14
+ * invalidation) — the single rule that closes every event-gap class
15
+ * (forced maxDuration reconnects, network blips, tab sleep);
16
+ * - visibility gating: hidden → 45s grace → disconnect; visible →
17
+ * reconnect + resync;
18
+ * - `markRead` with local write-through zeroing (no interval polling
19
+ * exists — a stale post-close map would re-light the dot forever).
20
+ *
21
+ * There is NO polling anywhere: the summary query has no
22
+ * `refetchInterval`; it refetches on mount, (re)connect, window focus,
23
+ * and (debounced) stream events. Hosts whose stream endpoint 404s
24
+ * (terminal) or is absent keep exactly focus/mount freshness.
25
+ *
26
+ * Invalidation semantics on events — deliberately `invalidateQueries`,
27
+ * NOT `setQueryData` patches: the ticket hooks run `gcTime: 0`, so
28
+ * there is usually no cache entry to patch, and `TicketsCacheSlot`
29
+ * shape rules apply when there is.
30
+ */
31
+
32
+ import React from 'react'
33
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
34
+ import { useChatRuntime } from '../../contexts/chat-runtime-context'
35
+ import { embedAuthedFetch } from '../../utils/embed-authed-fetch'
36
+ import {
37
+ createSseSubscription,
38
+ type SseSubscription,
39
+ type SseTransportStatus,
40
+ } from '../../utils/sse-subscription'
41
+ import { useChatIdentity } from '../chat/hooks/use-chat-identity'
42
+ import type {
43
+ TicketMessageStreamData,
44
+ TicketStreamEvent,
45
+ TicketUnreadSummary,
46
+ } from './types'
47
+
48
+ const TICKET_STREAM_ENDPOINT = '/api/chat/agent/ticket-stream'
49
+ const TICKET_READ_ENDPOINT = '/api/chat/agent/ticket-read'
50
+ const TICKET_UNREAD_SUMMARY_ENDPOINT = '/api/chat/agent/ticket-unread-summary'
51
+
52
+ /** Trailing debounce for event-burst invalidations (an agent pasting 5
53
+ * messages must not fire 5 list refetches). */
54
+ const INVALIDATE_DEBOUNCE_MS = 1_500
55
+ /** Grace before disconnecting a hidden tab (Cmd-Tab thrash protection). */
56
+ const HIDDEN_DISCONNECT_GRACE_MS = 45_000
57
+ /** No `status: subscribed` within this window after transport-open →
58
+ * treat as disconnected and force a client-side reconnect. */
59
+ const SUBSCRIBE_CONFIRM_TIMEOUT_MS = 15_000
60
+ /** After a server-reported `reconnect_failed`, the next client attempt
61
+ * starts at the CAPPED delay — each reconnect costs a full server
62
+ * invocation + the server's own Realtime retries; an outage must not
63
+ * become a stampede. */
64
+ const FAILED_RECONNECT_DELAY_MS = 30_000
65
+ /** A server `retrying` status means the server is recovering the channel
66
+ * itself (exponential backoff, ~5 attempts). Give it this long to reach
67
+ * `subscribed` before the client hard-reconnects. */
68
+ const SERVER_RETRY_GRACE_MS = 90_000
69
+
70
+ export interface TicketLiveContextValue {
71
+ /** Viewer has a resolved, non-anon chat identity. Surfaces (e.g. the
72
+ * header cell) hide themselves when false — computed here so they
73
+ * don't each spin up their own identity resolution. */
74
+ authed: boolean
75
+ /** Realtime-subscribed (server-confirmed), not merely HTTP-open. */
76
+ connected: boolean
77
+ /** Unread total across tickets — drives the header dot. */
78
+ unreadTotal: number
79
+ /** Per-ticket unread counts (missing key = 0) — drives row dots.
80
+ * The currently-open ticket is masked to 0. */
81
+ unreadByTicket: Record<string, number>
82
+ /** Mark a ticket read: POSTs the receipt, then locally zeroes the
83
+ * ticket in the summary map (write-through; reconciled by the next
84
+ * real refetch). */
85
+ markRead: (ticketExternalId: string) => Promise<void>
86
+ /** Drawer open/close registration — suppresses unread bumps for the
87
+ * open ticket and masks it in the derived map. */
88
+ setOpenTicketId: (ticketExternalId: string | null) => void
89
+ /** Hint that the viewer just created a ticket — retries a `no-stream`
90
+ * (204) subscription. */
91
+ notifyTicketCreated: () => void
92
+ }
93
+
94
+ const TicketLiveContext = React.createContext<TicketLiveContextValue | null>(null)
95
+
96
+ export function useOptionalTicketLive(): TicketLiveContextValue | null {
97
+ return React.useContext(TicketLiveContext)
98
+ }
99
+
100
+ export function useTicketLive(): TicketLiveContextValue {
101
+ const ctx = React.useContext(TicketLiveContext)
102
+ if (!ctx) {
103
+ throw new Error('useTicketLive must be used within <TicketLiveProvider>')
104
+ }
105
+ return ctx
106
+ }
107
+
108
+ function isTicketMessageData(value: unknown): value is TicketMessageStreamData {
109
+ return (
110
+ typeof value === 'object' &&
111
+ value !== null &&
112
+ typeof (value as TicketMessageStreamData).ticket_external_id === 'string'
113
+ )
114
+ }
115
+
116
+ export function TicketLiveProvider({ children }: { children: React.ReactNode }) {
117
+ const runtime = useChatRuntime()
118
+ const identity = useChatIdentity()
119
+ const queryClient = useQueryClient()
120
+
121
+ const streamUrl = runtime?.endpoints.ticketStreamUrl ?? TICKET_STREAM_ENDPOINT
122
+ const readUrl = runtime?.endpoints.ticketReadUrl ?? TICKET_READ_ENDPOINT
123
+ const summaryUrl =
124
+ runtime?.endpoints.ticketUnreadSummaryUrl ?? TICKET_UNREAD_SUMMARY_ENDPOINT
125
+
126
+ const identityKey = identity.user?.email ?? 'anon'
127
+ const authed = identity.authTier !== 'anon' && !!identity.user?.email
128
+
129
+ const [connected, setConnected] = React.useState(false)
130
+ // State + ref for the open drawer: state drives the derived map,
131
+ // the ref lets stream callbacks read it without re-subscribing.
132
+ const [openTicketIdState, setOpenTicketIdState] = React.useState<string | null>(null)
133
+ const openTicketIdRef = React.useRef<string | null>(null)
134
+
135
+ const summaryQueryKey = React.useMemo(
136
+ () => ['ticket-unread-summary', identityKey] as const,
137
+ [identityKey],
138
+ )
139
+
140
+ // ONE unread source. No refetchInterval — freshness is event-driven
141
+ // (mount / focus / (re)connect / debounced stream events).
142
+ const summaryQuery = useQuery({
143
+ queryKey: summaryQueryKey,
144
+ enabled: authed,
145
+ staleTime: 0,
146
+ refetchOnMount: 'always',
147
+ refetchOnWindowFocus: true,
148
+ queryFn: async (): Promise<TicketUnreadSummary> => {
149
+ const response = await embedAuthedFetch(summaryUrl, {
150
+ method: 'POST',
151
+ body: JSON.stringify({}),
152
+ })
153
+ if (!response.ok) {
154
+ throw new Error(`ticket-unread-summary failed: ${response.status}`)
155
+ }
156
+ const body = (await response.json()) as Partial<TicketUnreadSummary>
157
+ return {
158
+ totalUnread: typeof body.totalUnread === 'number' ? body.totalUnread : 0,
159
+ tickets: body.tickets ?? {},
160
+ }
161
+ },
162
+ })
163
+
164
+ const refetchSummary = React.useCallback(() => {
165
+ void queryClient.invalidateQueries({ queryKey: summaryQueryKey })
166
+ }, [queryClient, summaryQueryKey])
167
+
168
+ // ---- Debounced (trailing) invalidation of ticket queries on events ----
169
+ const pendingTicketIdsRef = React.useRef<Set<string>>(new Set())
170
+ const invalidateTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
171
+ const flushInvalidations = React.useCallback(() => {
172
+ invalidateTimerRef.current = null
173
+ const ids = Array.from(pendingTicketIdsRef.current)
174
+ pendingTicketIdsRef.current.clear()
175
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] })
176
+ for (const id of ids) {
177
+ void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', id] })
178
+ }
179
+ refetchSummary()
180
+ }, [queryClient, refetchSummary])
181
+ const scheduleInvalidation = React.useCallback(
182
+ (ticketId?: string) => {
183
+ if (ticketId) pendingTicketIdsRef.current.add(ticketId)
184
+ if (invalidateTimerRef.current) return
185
+ invalidateTimerRef.current = setTimeout(flushInvalidations, INVALIDATE_DEBOUNCE_MS)
186
+ },
187
+ [flushInvalidations],
188
+ )
189
+
190
+ // ---- Optimistic unread bump (reconciled by summary refetches) ----
191
+ const bumpUnread = React.useCallback(
192
+ (ticketId: string) => {
193
+ queryClient.setQueryData<TicketUnreadSummary>(summaryQueryKey, (prev) => {
194
+ const base = prev ?? { totalUnread: 0, tickets: {} }
195
+ return {
196
+ totalUnread: base.totalUnread + 1,
197
+ tickets: { ...base.tickets, [ticketId]: (base.tickets[ticketId] ?? 0) + 1 },
198
+ }
199
+ })
200
+ },
201
+ [queryClient, summaryQueryKey],
202
+ )
203
+
204
+ const zeroUnread = React.useCallback(
205
+ (ticketId: string) => {
206
+ queryClient.setQueryData<TicketUnreadSummary>(summaryQueryKey, (prev) => {
207
+ if (!prev) return prev
208
+ const current = prev.tickets[ticketId] ?? 0
209
+ if (current === 0) return prev
210
+ const tickets = { ...prev.tickets }
211
+ delete tickets[ticketId]
212
+ return { totalUnread: Math.max(0, prev.totalUnread - current), tickets }
213
+ })
214
+ },
215
+ [queryClient, summaryQueryKey],
216
+ )
217
+
218
+ // ---- Stream lifecycle ----
219
+ const subscriptionRef = React.useRef<SseSubscription | null>(null)
220
+ const noStreamRef = React.useRef(false)
221
+ const subscribeConfirmTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
222
+ const serverRecoveryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
223
+ const failedReconnectTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
224
+ const hiddenGraceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
225
+
226
+ const clearTimer = (ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>) => {
227
+ if (ref.current) {
228
+ clearTimeout(ref.current)
229
+ ref.current = null
230
+ }
231
+ }
232
+
233
+ // Stable handler refs so the subscription effect doesn't churn.
234
+ const handlersRef = React.useRef<{
235
+ onEvent: (eventName: string, data: unknown) => void
236
+ onStatus: (status: SseTransportStatus) => void
237
+ }>({ onEvent: () => {}, onStatus: () => {} })
238
+
239
+ const hardReconnect = React.useCallback((delayMs: number) => {
240
+ clearTimer(failedReconnectTimerRef)
241
+ if (delayMs <= 0) {
242
+ subscriptionRef.current?.reconnectNow()
243
+ return
244
+ }
245
+ failedReconnectTimerRef.current = setTimeout(() => {
246
+ failedReconnectTimerRef.current = null
247
+ subscriptionRef.current?.reconnectNow()
248
+ }, delayMs)
249
+ }, [])
250
+
251
+ handlersRef.current.onEvent = (eventName, data) => {
252
+ if (eventName === 'status') {
253
+ const status = (data as { status?: string } | null)?.status
254
+ if (status === 'subscribed') {
255
+ clearTimer(subscribeConfirmTimerRef)
256
+ clearTimer(serverRecoveryTimerRef)
257
+ setConnected(true)
258
+ // Resync on EVERY (re)connect — closes all event-gap classes.
259
+ refetchSummary()
260
+ const openId = openTicketIdRef.current
261
+ if (openId) {
262
+ void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', openId] })
263
+ }
264
+ } else if (status === 'retrying') {
265
+ // Server is recovering its Realtime channel itself — wait for it
266
+ // (bounded), don't stampede with a client reconnect.
267
+ setConnected(false)
268
+ if (!serverRecoveryTimerRef.current) {
269
+ serverRecoveryTimerRef.current = setTimeout(() => {
270
+ serverRecoveryTimerRef.current = null
271
+ hardReconnect(0)
272
+ }, SERVER_RETRY_GRACE_MS)
273
+ }
274
+ } else if (status === 'reconnect_failed') {
275
+ setConnected(false)
276
+ clearTimer(serverRecoveryTimerRef)
277
+ hardReconnect(FAILED_RECONNECT_DELAY_MS)
278
+ }
279
+ return
280
+ }
281
+
282
+ if (eventName === 'ticket-resync') {
283
+ refetchSummary()
284
+ scheduleInvalidation()
285
+ return
286
+ }
287
+
288
+ if (eventName === 'ticket-message') {
289
+ const frame = data as TicketStreamEvent | null
290
+ const payload = frame?.data
291
+ if (!isTicketMessageData(payload)) {
292
+ // Malformed/degraded — treat like a resync hint.
293
+ refetchSummary()
294
+ scheduleInvalidation()
295
+ return
296
+ }
297
+ // Always invalidate (own replies from another tab render live);
298
+ // bump unread ONLY on the server-stamped predicate, and never for
299
+ // the ticket whose drawer is open.
300
+ scheduleInvalidation(payload.ticket_external_id)
301
+ if (payload.countsAsUnread && payload.ticket_external_id !== openTicketIdRef.current) {
302
+ bumpUnread(payload.ticket_external_id)
303
+ }
304
+ }
305
+ }
306
+
307
+ handlersRef.current.onStatus = (status) => {
308
+ if (status === 'open') {
309
+ // Transport open ≠ connected. Require `status: subscribed` within
310
+ // the confirm window or force a reconnect.
311
+ noStreamRef.current = false
312
+ clearTimer(subscribeConfirmTimerRef)
313
+ subscribeConfirmTimerRef.current = setTimeout(() => {
314
+ subscribeConfirmTimerRef.current = null
315
+ setConnected(false)
316
+ hardReconnect(0)
317
+ }, SUBSCRIBE_CONFIRM_TIMEOUT_MS)
318
+ return
319
+ }
320
+ if (status === 'no-stream') {
321
+ // Zero owned tickets (204). Retried on create_ticket / visibility.
322
+ noStreamRef.current = true
323
+ }
324
+ // Every non-'open' transport status means we are not subscribed.
325
+ clearTimer(subscribeConfirmTimerRef)
326
+ setConnected(false)
327
+ }
328
+
329
+ // Mount/unmount of the subscription — gated on auth + a usable URL.
330
+ const streamEnabled = authed && !!streamUrl
331
+ React.useEffect(() => {
332
+ if (!streamEnabled) return
333
+ if (typeof window === 'undefined') return
334
+
335
+ const subscription = createSseSubscription({
336
+ url: streamUrl,
337
+ onEvent: (name, data) => handlersRef.current.onEvent(name, data),
338
+ onStatusChange: (status) => handlersRef.current.onStatus(status),
339
+ })
340
+ subscriptionRef.current = subscription
341
+
342
+ const retryIfIdle = () => {
343
+ // After a 204 (no-stream) or while disconnected-hidden, a
344
+ // visibility/focus/online signal is the retry trigger.
345
+ if (noStreamRef.current) {
346
+ noStreamRef.current = false
347
+ subscription.reconnectNow()
348
+ }
349
+ }
350
+
351
+ const onVisibilityChange = () => {
352
+ if (document.visibilityState === 'hidden') {
353
+ if (!hiddenGraceTimerRef.current) {
354
+ hiddenGraceTimerRef.current = setTimeout(() => {
355
+ hiddenGraceTimerRef.current = null
356
+ // Scale relief: a hidden tab holds no server invocation.
357
+ setConnected(false)
358
+ subscription.close()
359
+ subscriptionRef.current = null
360
+ }, HIDDEN_DISCONNECT_GRACE_MS)
361
+ }
362
+ return
363
+ }
364
+ // Visible again — cancel the grace, or recreate if already torn down.
365
+ clearTimer(hiddenGraceTimerRef)
366
+ if (!subscriptionRef.current) {
367
+ subscriptionRef.current = createSseSubscription({
368
+ url: streamUrl,
369
+ onEvent: (name, data) => handlersRef.current.onEvent(name, data),
370
+ onStatusChange: (status) => handlersRef.current.onStatus(status),
371
+ })
372
+ } else {
373
+ retryIfIdle()
374
+ }
375
+ // Resync regardless — the tab may have slept through events.
376
+ refetchSummary()
377
+ }
378
+
379
+ const onOnline = () => {
380
+ subscriptionRef.current?.reconnectNow()
381
+ refetchSummary()
382
+ }
383
+
384
+ document.addEventListener('visibilitychange', onVisibilityChange)
385
+ window.addEventListener('online', onOnline)
386
+ window.addEventListener('focus', retryIfIdle)
387
+
388
+ return () => {
389
+ document.removeEventListener('visibilitychange', onVisibilityChange)
390
+ window.removeEventListener('online', onOnline)
391
+ window.removeEventListener('focus', retryIfIdle)
392
+ clearTimer(hiddenGraceTimerRef)
393
+ clearTimer(subscribeConfirmTimerRef)
394
+ clearTimer(serverRecoveryTimerRef)
395
+ clearTimer(failedReconnectTimerRef)
396
+ if (invalidateTimerRef.current) {
397
+ clearTimeout(invalidateTimerRef.current)
398
+ invalidateTimerRef.current = null
399
+ }
400
+ subscriptionRef.current?.close()
401
+ subscriptionRef.current = null
402
+ setConnected(false)
403
+ }
404
+ // handlersRef is stable; streamUrl/auth changes remount the stream.
405
+ // eslint-disable-next-line react-hooks/exhaustive-deps
406
+ }, [streamEnabled, streamUrl, refetchSummary])
407
+
408
+ // ---- Public API ----
409
+ const markRead = React.useCallback(
410
+ async (ticketExternalId: string) => {
411
+ try {
412
+ const response = await embedAuthedFetch(readUrl, {
413
+ method: 'POST',
414
+ body: JSON.stringify({ ticket_id: ticketExternalId }),
415
+ })
416
+ if (response.ok) {
417
+ // Write-through zero — without polling, a stale post-close map
418
+ // would re-light the dot until the next focus/event/reconnect.
419
+ zeroUnread(ticketExternalId)
420
+ }
421
+ } catch {
422
+ // Non-fatal — the receipt lands on the next markRead; unread
423
+ // state reconciles via the summary.
424
+ }
425
+ },
426
+ [readUrl, zeroUnread],
427
+ )
428
+
429
+ const setOpenTicketId = React.useCallback((ticketExternalId: string | null) => {
430
+ openTicketIdRef.current = ticketExternalId
431
+ setOpenTicketIdState(ticketExternalId)
432
+ }, [])
433
+
434
+ const notifyTicketCreated = React.useCallback(() => {
435
+ refetchSummary()
436
+ if (noStreamRef.current) {
437
+ noStreamRef.current = false
438
+ subscriptionRef.current?.reconnectNow()
439
+ }
440
+ }, [refetchSummary])
441
+
442
+ // Derived unread map — open ticket masked to 0 so a summary refetch
443
+ // inside the markRead debounce window can't transiently re-badge it.
444
+ const value = React.useMemo<TicketLiveContextValue>(() => {
445
+ const summary = summaryQuery.data ?? { totalUnread: 0, tickets: {} }
446
+ let unreadByTicket = summary.tickets
447
+ let unreadTotal = summary.totalUnread
448
+ if (openTicketIdState && unreadByTicket[openTicketIdState]) {
449
+ const masked = unreadByTicket[openTicketIdState]
450
+ unreadByTicket = { ...unreadByTicket }
451
+ delete unreadByTicket[openTicketIdState]
452
+ unreadTotal = Math.max(0, unreadTotal - masked)
453
+ }
454
+ return {
455
+ authed,
456
+ connected,
457
+ unreadTotal,
458
+ unreadByTicket,
459
+ markRead,
460
+ setOpenTicketId,
461
+ notifyTicketCreated,
462
+ }
463
+ }, [
464
+ summaryQuery.data,
465
+ authed,
466
+ connected,
467
+ openTicketIdState,
468
+ markRead,
469
+ setOpenTicketId,
470
+ notifyTicketCreated,
471
+ ])
472
+
473
+ return <TicketLiveContext.Provider value={value}>{children}</TicketLiveContext.Provider>
474
+ }
@@ -212,18 +212,69 @@ export interface MappedTicketActionError {
212
212
  export const TICKET_TEXT_MAX_CHARS = 5000
213
213
 
214
214
  /**
215
- * Live-refresh cadence (ms) for an OPEN ticket drawer. Drives BOTH
216
- * surfaces that must stay current while the customer is looking at a
217
- * ticket:
218
- * - the ticket LIST poll (`useTicketsList.refetchInterval`) surfaces
219
- * out-of-band status / pipeline / priority / assignee changes;
220
- * - the CONVERSATION poll (`useTicketEngagements.refetchInterval`) →
221
- * surfaces new agent replies + attachments.
222
- * Single source of truth so the two surfaces never drift. Both leave
223
- * `refetchIntervalInBackground` at its default (false), so polling pauses
224
- * on a hidden tab — no wasted requests when the user tabs away.
215
+ * Wire contract of the ticket live stream (`GET /api/chat/agent/
216
+ * ticket-stream`, SSE). Producer (the hub route's `mapEvent`) imports
217
+ * THIS type — same producer/consumer SSOT pattern as
218
+ * `ConversationEngagementWire = TicketEngagement`. A schema drift here
219
+ * would be a silent serialization bug; keep one named contract.
220
+ *
221
+ * Frames carry METADATA ONLY — never message bodies. Content is always
222
+ * refetched through the ACL'd `list-engagements` path (one read path).
223
+ *
224
+ * Event names (`eventType`):
225
+ * - `ticket-message` — a message row landed in the mirror for one of
226
+ * the viewer's tickets. `data` carries the metadata.
227
+ * - `ticket-resync` — degraded frame: something happened that the
228
+ * server couldn't fully describe (truncated Realtime payload, or
229
+ * the connection's ownership set just gained tickets). Clients
230
+ * respond by refetching the unread summary + invalidating queries.
231
+ *
232
+ * The server also emits `status` frames (`subscribed` / `retrying` /
233
+ * `reconnect_failed`) from the shared SSE utility — clients derive
234
+ * `connected` from those, NOT from the HTTP stream being open.
235
+ */
236
+ export type TicketStreamEventType = 'ticket-message' | 'ticket-resync'
237
+
238
+ /** Metadata payload of a `ticket-message` frame. */
239
+ export type TicketMessageStreamData = {
240
+ ticket_external_id: string
241
+ /** Mirror row's HubSpot message id. */
242
+ message_id: string
243
+ /** Server-stamped via the same direction predicate the wire mapper
244
+ * uses. Clients never re-derive author classification. */
245
+ authorRole: 'customer' | 'support'
246
+ /** Server-stamped via the visibility+direction unread predicate
247
+ * (`countsAsUnread` in the hub's conversations DAL). ONLY frames
248
+ * with `true` may bump client-side unread state. */
249
+ countsAsUnread: boolean
250
+ hubspot_created_at: string | null
251
+ }
252
+
253
+ export interface TicketStreamEvent {
254
+ eventType: TicketStreamEventType
255
+ /** Human-readable label (SSE envelope compat) — clients ignore it. */
256
+ message: string
257
+ /** Present on `ticket-message`; absent on `ticket-resync`. */
258
+ data?: TicketMessageStreamData
259
+ }
260
+
261
+ /**
262
+ * Wire shape of `POST /api/chat/agent/ticket-unread-summary` — the
263
+ * SINGLE unread source. Header badge total AND per-row dots both read
264
+ * this via the `TicketLiveProvider` map; missing keys mean 0.
265
+ */
266
+ export interface TicketUnreadSummary {
267
+ totalUnread: number
268
+ tickets: Record<string, number>
269
+ }
270
+
271
+ /**
272
+ * Trailing debounce for `markRead` while the drawer is open and new
273
+ * messages stream in — flushed on drawer close so a fast open/close
274
+ * still persists the receipt. Named beside its sibling cadences so
275
+ * every timing knob lives in one file.
225
276
  */
226
- export const TICKET_LIVE_POLL_MS = 8000
277
+ export const TICKET_MARK_READ_DEBOUNCE_MS = 2000
227
278
 
228
279
  /**
229
280
  * Centralized toast copy. Keep all wording here so QA / localization