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

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-LILQJ3UZ.cjs → chunk-3ENGUBJP.cjs} +1 -1
  2. package/dist/chunk-3ENGUBJP.cjs.map +1 -0
  3. package/dist/{chunk-SFJRMBJC.js → chunk-5SVNA7IU.js} +2 -2
  4. package/dist/{chunk-VCPTU4IW.js → chunk-7CVRJPHL.js} +7 -7
  5. package/dist/{chunk-FTOVZ7O2.js → chunk-7LV55KB5.js} +7 -7
  6. package/dist/{chunk-RR3Z52X5.cjs → chunk-7XCEZFPP.cjs} +42 -42
  7. package/dist/{chunk-RR3Z52X5.cjs.map → chunk-7XCEZFPP.cjs.map} +1 -1
  8. package/dist/{chunk-SGWPW2AO.cjs → chunk-B35VOXDB.cjs} +18 -18
  9. package/dist/{chunk-SGWPW2AO.cjs.map → chunk-B35VOXDB.cjs.map} +1 -1
  10. package/dist/{chunk-OAWFYZZT.js → chunk-BTCNF6UL.js} +3 -3
  11. package/dist/{chunk-VO2ZONMR.js → chunk-BZFZDP34.js} +3 -3
  12. package/dist/{chunk-AWDHRIL3.cjs → chunk-CZK6MOF5.cjs} +75 -75
  13. package/dist/{chunk-AWDHRIL3.cjs.map → chunk-CZK6MOF5.cjs.map} +1 -1
  14. package/dist/{chunk-JGXAVNZQ.js → chunk-DKKFHGPC.js} +2511 -1886
  15. package/dist/chunk-DKKFHGPC.js.map +1 -0
  16. package/dist/{chunk-4SR2QHMB.js → chunk-GPYW5OLH.js} +3 -3
  17. package/dist/{chunk-F7AD5CUU.js → chunk-H3N4P3CG.js} +3 -3
  18. package/dist/chunk-H3N4P3CG.js.map +1 -0
  19. package/dist/{chunk-CLQEBWV7.js → chunk-KW6AALQM.js} +4 -4
  20. package/dist/{chunk-FFXNRJ5S.cjs → chunk-LOBR7IC6.cjs} +6 -6
  21. package/dist/chunk-LOBR7IC6.cjs.map +1 -0
  22. package/dist/{chunk-4UNQMYP7.cjs → chunk-O32Y4E4R.cjs} +3 -3
  23. package/dist/{chunk-4UNQMYP7.cjs.map → chunk-O32Y4E4R.cjs.map} +1 -1
  24. package/dist/{chunk-J4T5ZBWG.cjs → chunk-P35U4IUC.cjs} +19 -19
  25. package/dist/{chunk-J4T5ZBWG.cjs.map → chunk-P35U4IUC.cjs.map} +1 -1
  26. package/dist/{chunk-HR6F6IP4.js → chunk-P6KSCGPE.js} +78 -43
  27. package/dist/chunk-P6KSCGPE.js.map +1 -0
  28. package/dist/{chunk-G43CEISB.js → chunk-Q47BQIY2.js} +4 -4
  29. package/dist/{chunk-7L2RVINX.cjs → chunk-QJPNWK3A.cjs} +35 -35
  30. package/dist/{chunk-7L2RVINX.cjs.map → chunk-QJPNWK3A.cjs.map} +1 -1
  31. package/dist/{chunk-3T4SMJM5.cjs → chunk-QJRKUY5B.cjs} +181 -146
  32. package/dist/chunk-QJRKUY5B.cjs.map +1 -0
  33. package/dist/{chunk-72DF3QTA.cjs → chunk-QVCWDS3E.cjs} +14 -14
  34. package/dist/{chunk-72DF3QTA.cjs.map → chunk-QVCWDS3E.cjs.map} +1 -1
  35. package/dist/{chunk-WHJGRRK2.js → chunk-QW7SW5HY.js} +52 -43
  36. package/dist/chunk-QW7SW5HY.js.map +1 -0
  37. package/dist/{chunk-UCV4LFUG.cjs → chunk-RND7HWEA.cjs} +40 -40
  38. package/dist/{chunk-UCV4LFUG.cjs.map → chunk-RND7HWEA.cjs.map} +1 -1
  39. package/dist/{chunk-VYEDTVIZ.js → chunk-S2HMBVOE.js} +4 -4
  40. package/dist/{chunk-5RIHLXEP.cjs → chunk-SOVH7GXF.cjs} +33 -33
  41. package/dist/{chunk-5RIHLXEP.cjs.map → chunk-SOVH7GXF.cjs.map} +1 -1
  42. package/dist/{chunk-2OXJ6NHI.cjs → chunk-SRU5IAEG.cjs} +7 -7
  43. package/dist/{chunk-2OXJ6NHI.cjs.map → chunk-SRU5IAEG.cjs.map} +1 -1
  44. package/dist/{chunk-XV5SXALY.js → chunk-SVZLM4LJ.js} +3 -3
  45. package/dist/{chunk-L5CI57RM.cjs → chunk-T3BF6JTQ.cjs} +52 -43
  46. package/dist/chunk-T3BF6JTQ.cjs.map +1 -0
  47. package/dist/{chunk-BZF63JXP.js → chunk-VIUP7TKR.js} +6 -6
  48. package/dist/{chunk-JIZJDAXI.cjs → chunk-WQDTJHVQ.cjs} +12 -12
  49. package/dist/{chunk-JIZJDAXI.cjs.map → chunk-WQDTJHVQ.cjs.map} +1 -1
  50. package/dist/{chunk-6GT557KN.js → chunk-XKIA4XVE.js} +1 -1
  51. package/dist/chunk-XKIA4XVE.js.map +1 -0
  52. package/dist/{chunk-DMXB3VE2.cjs → chunk-ZUBCKADQ.cjs} +1884 -1259
  53. package/dist/chunk-ZUBCKADQ.cjs.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 +171 -165
  71. package/dist/components/index.cjs.map +1 -1
  72. package/dist/components/index.js +19 -13
  73. package/dist/components/index.js.map +1 -1
  74. package/dist/components/navigation/app-header.d.ts +12 -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 +11 -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 +10 -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 +40 -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 +30 -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 +62 -0
  109. package/dist/components/tickets/ticket-live-provider.d.ts.map +1 -0
  110. package/dist/components/tickets/types.d.ts +62 -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 +13 -5
  126. package/dist/index.cjs.map +1 -1
  127. package/dist/index.js +12 -4
  128. package/dist/types/navigation.d.ts +12 -0
  129. package/dist/types/navigation.d.ts.map +1 -1
  130. package/dist/utils/index.cjs +178 -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 +178 -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 +41 -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 +42 -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 +79 -0
  150. package/src/components/navigation/top-navigation.tsx +17 -1
  151. package/src/components/navigation/unread-dot.tsx +61 -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 +4 -13
  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 +506 -0
  163. package/src/components/tickets/types.ts +66 -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 +12 -0
  169. package/src/utils/index.ts +12 -0
  170. package/src/utils/sse-subscription.ts +289 -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-VCPTU4IW.js.map → chunk-7CVRJPHL.js.map} +0 -0
  183. /package/dist/{chunk-FTOVZ7O2.js.map → chunk-7LV55KB5.js.map} +0 -0
  184. /package/dist/{chunk-OAWFYZZT.js.map → chunk-BTCNF6UL.js.map} +0 -0
  185. /package/dist/{chunk-VO2ZONMR.js.map → chunk-BZFZDP34.js.map} +0 -0
  186. /package/dist/{chunk-4SR2QHMB.js.map → chunk-GPYW5OLH.js.map} +0 -0
  187. /package/dist/{chunk-CLQEBWV7.js.map → chunk-KW6AALQM.js.map} +0 -0
  188. /package/dist/{chunk-G43CEISB.js.map → chunk-Q47BQIY2.js.map} +0 -0
  189. /package/dist/{chunk-VYEDTVIZ.js.map → chunk-S2HMBVOE.js.map} +0 -0
  190. /package/dist/{chunk-XV5SXALY.js.map → chunk-SVZLM4LJ.js.map} +0 -0
  191. /package/dist/{chunk-BZF63JXP.js.map → chunk-VIUP7TKR.js.map} +0 -0
@@ -0,0 +1,506 @@
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 count badge. */
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
+ /** The ticket with the NEWEST unread reply (open ticket excluded) —
83
+ * the header cell routes to it (`?ticket=<id>#ticket-<id>` opens the
84
+ * drawer + scrolls the row). Null when nothing is unread. */
85
+ nextUnreadTicketId: string | null
86
+ /** Mark a ticket read: POSTs the receipt, then locally zeroes the
87
+ * ticket in the summary map (write-through; reconciled by the next
88
+ * real refetch). */
89
+ markRead: (ticketExternalId: string) => Promise<void>
90
+ /** Drawer open/close registration — suppresses unread bumps for the
91
+ * open ticket and masks it in the derived map. */
92
+ setOpenTicketId: (ticketExternalId: string | null) => void
93
+ /** Hint that the viewer just created a ticket — retries a `no-stream`
94
+ * (204) subscription. */
95
+ notifyTicketCreated: () => void
96
+ }
97
+
98
+ const TicketLiveContext = React.createContext<TicketLiveContextValue | null>(null)
99
+
100
+ export function useOptionalTicketLive(): TicketLiveContextValue | null {
101
+ return React.useContext(TicketLiveContext)
102
+ }
103
+
104
+ export function useTicketLive(): TicketLiveContextValue {
105
+ const ctx = React.useContext(TicketLiveContext)
106
+ if (!ctx) {
107
+ throw new Error('useTicketLive must be used within <TicketLiveProvider>')
108
+ }
109
+ return ctx
110
+ }
111
+
112
+ function isTicketMessageData(value: unknown): value is TicketMessageStreamData {
113
+ return (
114
+ typeof value === 'object' &&
115
+ value !== null &&
116
+ typeof (value as TicketMessageStreamData).ticket_external_id === 'string'
117
+ )
118
+ }
119
+
120
+ export function TicketLiveProvider({ children }: { children: React.ReactNode }) {
121
+ const runtime = useChatRuntime()
122
+ const identity = useChatIdentity()
123
+ const queryClient = useQueryClient()
124
+
125
+ const streamUrl = runtime?.endpoints.ticketStreamUrl ?? TICKET_STREAM_ENDPOINT
126
+ const readUrl = runtime?.endpoints.ticketReadUrl ?? TICKET_READ_ENDPOINT
127
+ const summaryUrl =
128
+ runtime?.endpoints.ticketUnreadSummaryUrl ?? TICKET_UNREAD_SUMMARY_ENDPOINT
129
+
130
+ const identityKey = identity.user?.email ?? 'anon'
131
+ const authed = identity.authTier !== 'anon' && !!identity.user?.email
132
+
133
+ const [connected, setConnected] = React.useState(false)
134
+ // State + ref for the open drawer: state drives the derived map,
135
+ // the ref lets stream callbacks read it without re-subscribing.
136
+ const [openTicketIdState, setOpenTicketIdState] = React.useState<string | null>(null)
137
+ const openTicketIdRef = React.useRef<string | null>(null)
138
+
139
+ const summaryQueryKey = React.useMemo(
140
+ () => ['ticket-unread-summary', identityKey] as const,
141
+ [identityKey],
142
+ )
143
+
144
+ // ONE unread source. No refetchInterval — freshness is event-driven
145
+ // (mount / focus / (re)connect / debounced stream events).
146
+ const summaryQuery = useQuery({
147
+ queryKey: summaryQueryKey,
148
+ enabled: authed,
149
+ staleTime: 0,
150
+ refetchOnMount: 'always',
151
+ refetchOnWindowFocus: true,
152
+ queryFn: async (): Promise<TicketUnreadSummary> => {
153
+ const response = await embedAuthedFetch(summaryUrl, {
154
+ method: 'POST',
155
+ body: JSON.stringify({}),
156
+ })
157
+ if (!response.ok) {
158
+ throw new Error(`ticket-unread-summary failed: ${response.status}`)
159
+ }
160
+ const body = (await response.json()) as Partial<TicketUnreadSummary>
161
+ return {
162
+ totalUnread: typeof body.totalUnread === 'number' ? body.totalUnread : 0,
163
+ tickets: body.tickets ?? {},
164
+ latestUnreadAt: body.latestUnreadAt ?? {},
165
+ }
166
+ },
167
+ })
168
+
169
+ const refetchSummary = React.useCallback(() => {
170
+ void queryClient.invalidateQueries({ queryKey: summaryQueryKey })
171
+ }, [queryClient, summaryQueryKey])
172
+
173
+ // ---- Debounced (trailing) invalidation of ticket queries on events ----
174
+ const pendingTicketIdsRef = React.useRef<Set<string>>(new Set())
175
+ const invalidateTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
176
+ const flushInvalidations = React.useCallback(() => {
177
+ invalidateTimerRef.current = null
178
+ const ids = Array.from(pendingTicketIdsRef.current)
179
+ pendingTicketIdsRef.current.clear()
180
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] })
181
+ for (const id of ids) {
182
+ void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', id] })
183
+ }
184
+ refetchSummary()
185
+ }, [queryClient, refetchSummary])
186
+ const scheduleInvalidation = React.useCallback(
187
+ (ticketId?: string) => {
188
+ if (ticketId) pendingTicketIdsRef.current.add(ticketId)
189
+ if (invalidateTimerRef.current) return
190
+ invalidateTimerRef.current = setTimeout(flushInvalidations, INVALIDATE_DEBOUNCE_MS)
191
+ },
192
+ [flushInvalidations],
193
+ )
194
+
195
+ // ---- Optimistic unread bump (reconciled by summary refetches) ----
196
+ const bumpUnread = React.useCallback(
197
+ (ticketId: string, createdAt?: string | null) => {
198
+ queryClient.setQueryData<TicketUnreadSummary>(summaryQueryKey, (prev) => {
199
+ const base = prev ?? { totalUnread: 0, tickets: {}, latestUnreadAt: {} }
200
+ const stamp = createdAt ?? new Date().toISOString()
201
+ const existing = base.latestUnreadAt[ticketId]
202
+ return {
203
+ totalUnread: base.totalUnread + 1,
204
+ tickets: { ...base.tickets, [ticketId]: (base.tickets[ticketId] ?? 0) + 1 },
205
+ latestUnreadAt: {
206
+ ...base.latestUnreadAt,
207
+ [ticketId]: existing && existing > stamp ? existing : stamp,
208
+ },
209
+ }
210
+ })
211
+ },
212
+ [queryClient, summaryQueryKey],
213
+ )
214
+
215
+ const zeroUnread = React.useCallback(
216
+ (ticketId: string) => {
217
+ queryClient.setQueryData<TicketUnreadSummary>(summaryQueryKey, (prev) => {
218
+ if (!prev) return prev
219
+ const current = prev.tickets[ticketId] ?? 0
220
+ if (current === 0) return prev
221
+ const tickets = { ...prev.tickets }
222
+ delete tickets[ticketId]
223
+ const latestUnreadAt = { ...prev.latestUnreadAt }
224
+ delete latestUnreadAt[ticketId]
225
+ return { totalUnread: Math.max(0, prev.totalUnread - current), tickets, latestUnreadAt }
226
+ })
227
+ },
228
+ [queryClient, summaryQueryKey],
229
+ )
230
+
231
+ // ---- Stream lifecycle ----
232
+ const subscriptionRef = React.useRef<SseSubscription | null>(null)
233
+ const noStreamRef = React.useRef(false)
234
+ const subscribeConfirmTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
235
+ const serverRecoveryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
236
+ const failedReconnectTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
237
+ const hiddenGraceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
238
+
239
+ const clearTimer = (ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>) => {
240
+ if (ref.current) {
241
+ clearTimeout(ref.current)
242
+ ref.current = null
243
+ }
244
+ }
245
+
246
+ // Stable handler refs so the subscription effect doesn't churn.
247
+ const handlersRef = React.useRef<{
248
+ onEvent: (eventName: string, data: unknown) => void
249
+ onStatus: (status: SseTransportStatus) => void
250
+ }>({ onEvent: () => {}, onStatus: () => {} })
251
+
252
+ const hardReconnect = React.useCallback((delayMs: number) => {
253
+ clearTimer(failedReconnectTimerRef)
254
+ if (delayMs <= 0) {
255
+ subscriptionRef.current?.reconnectNow()
256
+ return
257
+ }
258
+ failedReconnectTimerRef.current = setTimeout(() => {
259
+ failedReconnectTimerRef.current = null
260
+ subscriptionRef.current?.reconnectNow()
261
+ }, delayMs)
262
+ }, [])
263
+
264
+ handlersRef.current.onEvent = (eventName, data) => {
265
+ if (eventName === 'status') {
266
+ const status = (data as { status?: string } | null)?.status
267
+ if (status === 'subscribed') {
268
+ clearTimer(subscribeConfirmTimerRef)
269
+ clearTimer(serverRecoveryTimerRef)
270
+ setConnected(true)
271
+ // Resync on EVERY (re)connect — closes all event-gap classes.
272
+ refetchSummary()
273
+ const openId = openTicketIdRef.current
274
+ if (openId) {
275
+ void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', openId] })
276
+ }
277
+ } else if (status === 'retrying') {
278
+ // Server is recovering its Realtime channel itself — wait for it
279
+ // (bounded), don't stampede with a client reconnect.
280
+ setConnected(false)
281
+ if (!serverRecoveryTimerRef.current) {
282
+ serverRecoveryTimerRef.current = setTimeout(() => {
283
+ serverRecoveryTimerRef.current = null
284
+ hardReconnect(0)
285
+ }, SERVER_RETRY_GRACE_MS)
286
+ }
287
+ } else if (status === 'reconnect_failed') {
288
+ setConnected(false)
289
+ clearTimer(serverRecoveryTimerRef)
290
+ hardReconnect(FAILED_RECONNECT_DELAY_MS)
291
+ }
292
+ return
293
+ }
294
+
295
+ if (eventName === 'ticket-resync') {
296
+ refetchSummary()
297
+ scheduleInvalidation()
298
+ return
299
+ }
300
+
301
+ if (eventName === 'ticket-message') {
302
+ const frame = data as TicketStreamEvent | null
303
+ const payload = frame?.data
304
+ if (!isTicketMessageData(payload)) {
305
+ // Malformed/degraded — treat like a resync hint.
306
+ refetchSummary()
307
+ scheduleInvalidation()
308
+ return
309
+ }
310
+ // Always invalidate (own replies from another tab render live);
311
+ // bump unread ONLY on the server-stamped predicate, and never for
312
+ // the ticket whose drawer is open.
313
+ scheduleInvalidation(payload.ticket_external_id)
314
+ if (payload.countsAsUnread && payload.ticket_external_id !== openTicketIdRef.current) {
315
+ bumpUnread(payload.ticket_external_id, payload.hubspot_created_at)
316
+ }
317
+ }
318
+ }
319
+
320
+ handlersRef.current.onStatus = (status) => {
321
+ if (status === 'open') {
322
+ // Transport open ≠ connected. Require `status: subscribed` within
323
+ // the confirm window or force a reconnect.
324
+ noStreamRef.current = false
325
+ clearTimer(subscribeConfirmTimerRef)
326
+ subscribeConfirmTimerRef.current = setTimeout(() => {
327
+ subscribeConfirmTimerRef.current = null
328
+ setConnected(false)
329
+ hardReconnect(0)
330
+ }, SUBSCRIBE_CONFIRM_TIMEOUT_MS)
331
+ return
332
+ }
333
+ if (status === 'no-stream') {
334
+ // Zero owned tickets (204). Retried on create_ticket / visibility.
335
+ noStreamRef.current = true
336
+ }
337
+ // Every non-'open' transport status means we are not subscribed.
338
+ clearTimer(subscribeConfirmTimerRef)
339
+ setConnected(false)
340
+ }
341
+
342
+ // Mount/unmount of the subscription — gated on auth + a usable URL.
343
+ const streamEnabled = authed && !!streamUrl
344
+ React.useEffect(() => {
345
+ if (!streamEnabled) return
346
+ if (typeof window === 'undefined') return
347
+
348
+ const subscription = createSseSubscription({
349
+ url: streamUrl,
350
+ onEvent: (name, data) => handlersRef.current.onEvent(name, data),
351
+ onStatusChange: (status) => handlersRef.current.onStatus(status),
352
+ })
353
+ subscriptionRef.current = subscription
354
+
355
+ // `subscriptionRef.current` is the SINGLE source of truth in every
356
+ // handler below — never the `subscription` constant above. The visible
357
+ // branch recreates the subscription into the ref, so a captured
358
+ // constant would go stale after the first hide/show cycle: the next
359
+ // hide would close the already-dead original and leak the live
360
+ // replacement (which never terminates by design).
361
+ const retryIfIdle = () => {
362
+ // After a 204 (no-stream) or while disconnected-hidden, a
363
+ // visibility/focus/online signal is the retry trigger.
364
+ if (noStreamRef.current) {
365
+ noStreamRef.current = false
366
+ subscriptionRef.current?.reconnectNow()
367
+ }
368
+ }
369
+
370
+ const onVisibilityChange = () => {
371
+ if (document.visibilityState === 'hidden') {
372
+ if (!hiddenGraceTimerRef.current) {
373
+ hiddenGraceTimerRef.current = setTimeout(() => {
374
+ hiddenGraceTimerRef.current = null
375
+ // Scale relief: a hidden tab holds no server invocation.
376
+ setConnected(false)
377
+ subscriptionRef.current?.close()
378
+ subscriptionRef.current = null
379
+ }, HIDDEN_DISCONNECT_GRACE_MS)
380
+ }
381
+ return
382
+ }
383
+ // Visible again — cancel the grace, or recreate if already torn down.
384
+ clearTimer(hiddenGraceTimerRef)
385
+ if (!subscriptionRef.current) {
386
+ subscriptionRef.current = createSseSubscription({
387
+ url: streamUrl,
388
+ onEvent: (name, data) => handlersRef.current.onEvent(name, data),
389
+ onStatusChange: (status) => handlersRef.current.onStatus(status),
390
+ })
391
+ } else {
392
+ retryIfIdle()
393
+ }
394
+ // Resync regardless — the tab may have slept through events.
395
+ refetchSummary()
396
+ }
397
+
398
+ const onOnline = () => {
399
+ subscriptionRef.current?.reconnectNow()
400
+ refetchSummary()
401
+ }
402
+
403
+ document.addEventListener('visibilitychange', onVisibilityChange)
404
+ window.addEventListener('online', onOnline)
405
+ window.addEventListener('focus', retryIfIdle)
406
+
407
+ return () => {
408
+ document.removeEventListener('visibilitychange', onVisibilityChange)
409
+ window.removeEventListener('online', onOnline)
410
+ window.removeEventListener('focus', retryIfIdle)
411
+ clearTimer(hiddenGraceTimerRef)
412
+ clearTimer(subscribeConfirmTimerRef)
413
+ clearTimer(serverRecoveryTimerRef)
414
+ clearTimer(failedReconnectTimerRef)
415
+ if (invalidateTimerRef.current) {
416
+ clearTimeout(invalidateTimerRef.current)
417
+ invalidateTimerRef.current = null
418
+ }
419
+ subscriptionRef.current?.close()
420
+ subscriptionRef.current = null
421
+ setConnected(false)
422
+ }
423
+ // handlersRef is stable; streamUrl/auth changes remount the stream.
424
+ // eslint-disable-next-line react-hooks/exhaustive-deps
425
+ }, [streamEnabled, streamUrl, refetchSummary])
426
+
427
+ // ---- Public API ----
428
+ const markRead = React.useCallback(
429
+ async (ticketExternalId: string) => {
430
+ try {
431
+ const response = await embedAuthedFetch(readUrl, {
432
+ method: 'POST',
433
+ body: JSON.stringify({ ticket_id: ticketExternalId }),
434
+ })
435
+ if (response.ok) {
436
+ // Write-through zero — without polling, a stale post-close map
437
+ // would re-light the dot until the next focus/event/reconnect.
438
+ zeroUnread(ticketExternalId)
439
+ }
440
+ } catch {
441
+ // Non-fatal — the receipt lands on the next markRead; unread
442
+ // state reconciles via the summary.
443
+ }
444
+ },
445
+ [readUrl, zeroUnread],
446
+ )
447
+
448
+ const setOpenTicketId = React.useCallback((ticketExternalId: string | null) => {
449
+ openTicketIdRef.current = ticketExternalId
450
+ setOpenTicketIdState(ticketExternalId)
451
+ }, [])
452
+
453
+ const notifyTicketCreated = React.useCallback(() => {
454
+ refetchSummary()
455
+ if (noStreamRef.current) {
456
+ noStreamRef.current = false
457
+ subscriptionRef.current?.reconnectNow()
458
+ }
459
+ }, [refetchSummary])
460
+
461
+ // Derived unread map — open ticket masked to 0 so a summary refetch
462
+ // inside the markRead debounce window can't transiently re-badge it.
463
+ const value = React.useMemo<TicketLiveContextValue>(() => {
464
+ const summary = summaryQuery.data ?? { totalUnread: 0, tickets: {}, latestUnreadAt: {} }
465
+ let unreadByTicket = summary.tickets
466
+ let unreadTotal = summary.totalUnread
467
+ if (openTicketIdState && unreadByTicket[openTicketIdState]) {
468
+ const masked = unreadByTicket[openTicketIdState]
469
+ unreadByTicket = { ...unreadByTicket }
470
+ delete unreadByTicket[openTicketIdState]
471
+ unreadTotal = Math.max(0, unreadTotal - masked)
472
+ }
473
+ // Ticket with the NEWEST unread reply (ISO strings compare
474
+ // lexicographically) — header cell routes there. Falls back to any
475
+ // unread key if a timestamp is missing.
476
+ let nextUnreadTicketId: string | null = null
477
+ let nextStamp = ''
478
+ for (const id of Object.keys(unreadByTicket)) {
479
+ const stamp = summary.latestUnreadAt[id] ?? ''
480
+ if (nextUnreadTicketId === null || stamp > nextStamp) {
481
+ nextUnreadTicketId = id
482
+ nextStamp = stamp
483
+ }
484
+ }
485
+ return {
486
+ authed,
487
+ connected,
488
+ unreadTotal,
489
+ unreadByTicket,
490
+ nextUnreadTicketId,
491
+ markRead,
492
+ setOpenTicketId,
493
+ notifyTicketCreated,
494
+ }
495
+ }, [
496
+ summaryQuery.data,
497
+ authed,
498
+ connected,
499
+ openTicketIdState,
500
+ markRead,
501
+ setOpenTicketId,
502
+ notifyTicketCreated,
503
+ ])
504
+
505
+ return <TicketLiveContext.Provider value={value}>{children}</TicketLiveContext.Provider>
506
+ }
@@ -212,18 +212,73 @@ 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
+ /** ISO timestamp of the NEWEST unread message per ticket (same keys as
270
+ * `tickets`). Drives "route to the most recent update" on the header
271
+ * cell: the ticket with the max value here is `nextUnreadTicketId`. */
272
+ latestUnreadAt: Record<string, string>
273
+ }
274
+
275
+ /**
276
+ * Trailing debounce for `markRead` while the drawer is open and new
277
+ * messages stream in — flushed on drawer close so a fast open/close
278
+ * still persists the receipt. Named beside its sibling cadences so
279
+ * every timing knob lives in one file.
225
280
  */
226
- export const TICKET_LIVE_POLL_MS = 8000
281
+ export const TICKET_MARK_READ_DEBOUNCE_MS = 2000
227
282
 
228
283
  /**
229
284
  * Centralized toast copy. Keep all wording here so QA / localization