@flamingo-stack/openframe-frontend-core 0.0.210 → 0.0.212-snapshot.20260528112413

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 (128) hide show
  1. package/dist/{chunk-VBFOCTMD.cjs → chunk-35XIT2CF.cjs} +17 -17
  2. package/dist/{chunk-VBFOCTMD.cjs.map → chunk-35XIT2CF.cjs.map} +1 -1
  3. package/dist/{chunk-ATEUJQKU.js → chunk-3JWIJJ44.js} +2 -2
  4. package/dist/chunk-CZR7ARBA.js +698 -0
  5. package/dist/chunk-CZR7ARBA.js.map +1 -0
  6. package/dist/{chunk-UYQOPC57.js → chunk-HICZPTRR.js} +4 -351
  7. package/dist/chunk-HICZPTRR.js.map +1 -0
  8. package/dist/{chunk-WJBPLMBX.js → chunk-IK2X5YJU.js} +3 -3
  9. package/dist/{chunk-MDTIOPVS.cjs → chunk-OTKJASSX.cjs} +26 -26
  10. package/dist/{chunk-MDTIOPVS.cjs.map → chunk-OTKJASSX.cjs.map} +1 -1
  11. package/dist/chunk-OZ3GH6OQ.cjs +698 -0
  12. package/dist/chunk-OZ3GH6OQ.cjs.map +1 -0
  13. package/dist/{chunk-6RZYJICV.cjs → chunk-P5EE2VJX.cjs} +1 -1
  14. package/dist/chunk-P5EE2VJX.cjs.map +1 -0
  15. package/dist/{chunk-EH3RWVF3.cjs → chunk-WT5JV2GS.cjs} +8 -355
  16. package/dist/chunk-WT5JV2GS.cjs.map +1 -0
  17. package/dist/{chunk-TWKPYZNQ.cjs → chunk-ZDF6F7ED.cjs} +569 -694
  18. package/dist/chunk-ZDF6F7ED.cjs.map +1 -0
  19. package/dist/{chunk-7L4DWM7P.js → chunk-ZG2YY5E7.js} +1 -1
  20. package/dist/chunk-ZG2YY5E7.js.map +1 -0
  21. package/dist/{chunk-R5RNRH62.js → chunk-ZTJVRSN5.js} +422 -547
  22. package/dist/chunk-ZTJVRSN5.js.map +1 -0
  23. package/dist/components/chat/hooks/use-chat-identity.d.ts +3 -3
  24. package/dist/components/chat/hooks/use-chat-identity.d.ts.map +1 -1
  25. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  26. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts +0 -9
  27. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
  28. package/dist/components/chat/index.cjs +6 -5
  29. package/dist/components/chat/index.cjs.map +1 -1
  30. package/dist/components/chat/index.js +5 -4
  31. package/dist/components/contact/index.cjs +7 -6
  32. package/dist/components/contact/index.cjs.map +1 -1
  33. package/dist/components/contact/index.js +6 -5
  34. package/dist/components/features/index.cjs +6 -5
  35. package/dist/components/features/index.cjs.map +1 -1
  36. package/dist/components/features/index.js +5 -4
  37. package/dist/components/features/notifications/index.d.ts +2 -2
  38. package/dist/components/features/notifications/index.d.ts.map +1 -1
  39. package/dist/components/features/notifications/notifications-context.d.ts +16 -1
  40. package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
  41. package/dist/components/features/notifications/types.d.ts +4 -0
  42. package/dist/components/features/notifications/types.d.ts.map +1 -1
  43. package/dist/components/footer-waitlist-button.d.ts +21 -2
  44. package/dist/components/footer-waitlist-button.d.ts.map +1 -1
  45. package/dist/components/index.cjs +93 -96
  46. package/dist/components/index.cjs.map +1 -1
  47. package/dist/components/index.js +17 -20
  48. package/dist/components/index.js.map +1 -1
  49. package/dist/components/navigation/app-header.d.ts.map +1 -1
  50. package/dist/components/navigation/index.cjs +6 -5
  51. package/dist/components/navigation/index.cjs.map +1 -1
  52. package/dist/components/navigation/index.js +5 -4
  53. package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
  54. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  55. package/dist/components/tickets/index.cjs +144 -102
  56. package/dist/components/tickets/index.cjs.map +1 -1
  57. package/dist/components/tickets/index.js +98 -56
  58. package/dist/components/tickets/index.js.map +1 -1
  59. package/dist/components/tickets/ticket-row.d.ts.map +1 -1
  60. package/dist/components/ui/index.cjs +6 -5
  61. package/dist/components/ui/index.cjs.map +1 -1
  62. package/dist/components/ui/index.js +5 -4
  63. package/dist/contexts/chat-runtime-context.d.ts +6 -3
  64. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  65. package/dist/contexts/index.cjs +2 -2
  66. package/dist/contexts/index.js +1 -1
  67. package/dist/embed-shims/index.cjs +3 -3
  68. package/dist/embed-shims/index.cjs.map +1 -1
  69. package/dist/embed-shims/index.js +4 -4
  70. package/dist/hooks/index.cjs +3 -2
  71. package/dist/hooks/index.cjs.map +1 -1
  72. package/dist/hooks/index.js +2 -1
  73. package/dist/index.cjs +8 -5
  74. package/dist/index.cjs.map +1 -1
  75. package/dist/index.js +7 -4
  76. package/dist/nats/index.cjs +28 -346
  77. package/dist/nats/index.cjs.map +1 -1
  78. package/dist/nats/index.d.ts +3 -0
  79. package/dist/nats/index.d.ts.map +1 -1
  80. package/dist/nats/index.js +30 -346
  81. package/dist/nats/index.js.map +1 -1
  82. package/dist/nats/nats-provider.d.ts +28 -0
  83. package/dist/nats/nats-provider.d.ts.map +1 -0
  84. package/dist/nats/nats.d.ts +1 -0
  85. package/dist/nats/nats.d.ts.map +1 -1
  86. package/dist/nats/shared-connection.d.ts +73 -0
  87. package/dist/nats/shared-connection.d.ts.map +1 -0
  88. package/dist/nats/use-nats-subscription.d.ts +18 -0
  89. package/dist/nats/use-nats-subscription.d.ts.map +1 -0
  90. package/dist/utils/index.cjs +10 -0
  91. package/dist/utils/index.cjs.map +1 -1
  92. package/dist/utils/index.d.ts +1 -0
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +10 -1
  95. package/dist/utils/index.js.map +1 -1
  96. package/dist/utils/scroll-into-view.d.ts +63 -0
  97. package/dist/utils/scroll-into-view.d.ts.map +1 -0
  98. package/package.json +1 -1
  99. package/src/components/chat/hooks/use-chat-identity.ts +8 -7
  100. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +60 -207
  101. package/src/components/chat/hooks/use-nats-dialog-subscription.ts +71 -214
  102. package/src/components/features/notifications/index.ts +2 -1
  103. package/src/components/features/notifications/notifications-context.tsx +104 -6
  104. package/src/components/features/notifications/types.ts +5 -0
  105. package/src/components/footer-waitlist-button.tsx +33 -16
  106. package/src/components/navigation/app-header.tsx +7 -9
  107. package/src/components/navigation/sticky-section-nav.tsx +6 -4
  108. package/src/components/tickets/help-center-card.tsx +55 -1
  109. package/src/components/tickets/help-center-list.tsx +9 -1
  110. package/src/components/tickets/ticket-detail-drawer.tsx +19 -4
  111. package/src/components/tickets/ticket-row.tsx +30 -19
  112. package/src/contexts/chat-runtime-context.tsx +6 -3
  113. package/src/nats/index.ts +3 -0
  114. package/src/nats/nats-provider.tsx +146 -0
  115. package/src/nats/nats.ts +2 -0
  116. package/src/nats/shared-connection.ts +285 -0
  117. package/src/nats/use-nats-subscription.ts +99 -0
  118. package/src/stories/EmbeddableChat.stories.tsx +1 -1
  119. package/src/utils/index.ts +12 -0
  120. package/src/utils/scroll-into-view.ts +74 -0
  121. package/dist/chunk-6RZYJICV.cjs.map +0 -1
  122. package/dist/chunk-7L4DWM7P.js.map +0 -1
  123. package/dist/chunk-EH3RWVF3.cjs.map +0 -1
  124. package/dist/chunk-R5RNRH62.js.map +0 -1
  125. package/dist/chunk-TWKPYZNQ.cjs.map +0 -1
  126. package/dist/chunk-UYQOPC57.js.map +0 -1
  127. /package/dist/{chunk-ATEUJQKU.js.map → chunk-3JWIJJ44.js.map} +0 -0
  128. /package/dist/{chunk-WJBPLMBX.js.map → chunk-IK2X5YJU.js.map} +0 -0
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { usePathname, useRouter } from '../embed-shims/next-navigation';
3
+ import { useRouter } from '../embed-shims/next-navigation';
4
+ import { useChatRuntime } from '../contexts/chat-runtime-context';
4
5
  import { useCallback } from 'react';
5
6
  import { OpenFrameLogo } from './icons';
6
7
  import { Button } from './ui/button';
@@ -11,27 +12,43 @@ export interface FooterWaitlistButtonProps {
11
12
 
12
13
  /**
13
14
  * Small wrapper around JoinWaitlistButton for use inside the footer.
14
- * Provides a default click handler that scrolls/focuses the wait-list form
15
- * if the user is already on /waitlist; otherwise navigates to it.
15
+ *
16
+ * Routes through the host's unified-navigation hook
17
+ * (`runtime.navigation.navigate`) when a `ChatRuntimeContext` is
18
+ * mounted — that's the same path EVERY other in-app navigation
19
+ * surface uses (source chips, inline cards, search-autocomplete,
20
+ * action cards). One rule, one decision tree across the whole app.
21
+ * The hub's `HubRuntimeProvider` wires `navigate` to its `useUnifiedNav`
22
+ * helper, so this button picks up cross-platform new-tab decisions,
23
+ * same-URL re-scroll handling, embed-mode short-circuiting, and any
24
+ * future host-side nav rules for free.
25
+ *
26
+ * Falls back to the embed-shim's `router.push` when no runtime is
27
+ * mounted (third-party embedders who haven't set up
28
+ * `ChatRuntimeContext` — the lib stays usable without forcing them
29
+ * to wire the full chat-runtime).
30
+ *
31
+ * Target URL: `/waitlist#top`. `#top` is the canonical "scroll to
32
+ * page top" anchor — the destination page has an explicit
33
+ * `<div id="top">` at the top of `<main>` so native browser anchor
34
+ * scroll works in every browser regardless of the HTML5 magic-anchor
35
+ * behavior.
16
36
  */
17
37
  export function FooterWaitlistButton({ className }: FooterWaitlistButtonProps) {
18
38
  const router = useRouter();
19
- const pathname = usePathname();
39
+ const runtime = useChatRuntime();
20
40
 
21
41
  const handleClick = useCallback(() => {
22
- if (pathname?.startsWith('/waitlist')) {
23
- const anchor = document.getElementById('waitlist-form');
24
- if (anchor) {
25
- anchor.scrollIntoView({ behavior: 'smooth', block: 'center' } as any);
26
- setTimeout(() => {
27
- const input = anchor.querySelector('input[type="email"]') as HTMLInputElement | null;
28
- input?.focus();
29
- }, 400);
30
- return;
31
- }
42
+ const href = '/waitlist#top';
43
+ // Prefer the host's unified-nav callback (hub-wired
44
+ // `useUnifiedNav`). Falls back to the embed-shim's router when
45
+ // the host hasn't provided one.
46
+ if (runtime?.navigation?.navigate) {
47
+ const handled = runtime.navigation.navigate({ href });
48
+ if (handled) return;
32
49
  }
33
- router.push('/waitlist#waitlist-form');
34
- }, [pathname, router]);
50
+ router.push(href);
51
+ }, [router, runtime]);
35
52
 
36
53
  return (
37
54
  <Button
@@ -6,7 +6,6 @@ import { cn } from '../../utils/cn'
6
6
  import { useOptionalNotifications } from '../features/notifications/notifications-context'
7
7
  import { LogOutIcon, OpenFrameLogo, OpenFrameText, UserIcon } from '../icons'
8
8
  import { BellIcon } from '../icons-v2-generated/interface/bell-icon'
9
- import { BellRingingIcon } from '../icons-v2-generated/interface/bell-ringing-icon'
10
9
  import { Menu01Icon, SearchIcon, XmarkIcon } from '../icons-v2-generated'
11
10
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, SquareAvatar } from '../ui'
12
11
  import { HeaderButton } from './header-button'
@@ -210,20 +209,19 @@ function NotificationsHeaderButton({
210
209
  dimmedClass,
211
210
  }: NotificationsHeaderButtonProps) {
212
211
  const ctx = useOptionalNotifications()
213
- const unreadCount = ctx?.unreadCount ?? fallbackUnreadCount
212
+ const hasUnread = (ctx?.unreadCount ?? fallbackUnreadCount) > 0
214
213
  const isActive = ctx?.isOpen ?? false
215
214
  const onClick = ctx?.toggle
216
- const Icon = unreadCount > 0 ? BellRingingIcon : BellIcon
217
215
 
218
216
  return (
219
217
  <HeaderButton
220
218
  icon={
221
- <div className="relative w-full h-full flex items-center justify-center">
222
- <Icon className="h-4 w-4 md:w-6 md:h-6" />
223
- {unreadCount > 0 && (
224
- <span className="absolute top-2 right-2 bg-ods-accent text-ods-text-on-accent text-[8px] rounded-full w-3 h-3 md:w-4 md:h-4 flex items-center justify-center">
225
- {unreadCount > 9 ? '9+' : unreadCount}
226
- </span>
219
+ <div className="relative w-4 h-4 md:w-6 md:h-6">
220
+ <BellIcon className="w-full h-full" />
221
+ {hasUnread && (
222
+ <span
223
+ className="absolute top-0 right-0 bg-ods-warning rounded-full w-1.5 h-1.5 md:w-2 md:h-2"
224
+ />
227
225
  )}
228
226
  </div>
229
227
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useEffect, useState, useCallback, useRef } from 'react'
4
4
  import { cn } from '../../utils'
5
+ import { scrollElementIntoView } from '../../utils/scroll-into-view'
5
6
 
6
7
  export interface StickyNavSection {
7
8
  id: string
@@ -97,7 +98,10 @@ export function useSectionNavigation(
97
98
  const isScrollingFromClick = useRef(false)
98
99
  const { offset = 100 } = options || {}
99
100
 
100
- // Handle click - just scroll to the element
101
+ // Handle click - scroll to the element via the canonical helper.
102
+ // The `offset` prop maps to `headerOffset` (sticky chrome above the
103
+ // section nav); same smooth-scroll mechanics every other anchor
104
+ // surface in the app uses.
101
105
  const handleSectionClick = useCallback((sectionId: string) => {
102
106
  const targetElement = document.getElementById(sectionId)
103
107
  if (!targetElement) return
@@ -106,9 +110,7 @@ export function useSectionNavigation(
106
110
  isScrollingFromClick.current = true
107
111
  setActiveSection(sectionId)
108
112
 
109
- // Scroll to element
110
- const top = targetElement.offsetTop - offset
111
- window.scrollTo({ top, behavior: 'smooth' })
113
+ scrollElementIntoView(targetElement, { headerOffset: offset })
112
114
 
113
115
  // Allow scroll spy again after scroll completes
114
116
  setTimeout(() => {
@@ -15,8 +15,10 @@
15
15
  * is a SIBLING of the toggle button, not nested inside it.
16
16
  */
17
17
 
18
+ import { useCallback, useRef } from 'react'
18
19
  import { StatusBadge, type StatusBadgeProps } from '../ui'
19
20
  import { formatRelativeTime } from '../../utils/date-utils'
21
+ import { scrollElementIntoView } from '../../utils/scroll-into-view'
20
22
  import { getStatusColorScheme } from '../chat/utils/agent-status-message'
21
23
  import { DevCardRowContent } from '../shared/dev-section/dev-card-row'
22
24
  import {
@@ -26,6 +28,23 @@ import {
26
28
  import type { AnyTicket } from './types'
27
29
  import { isOptimistic } from './types'
28
30
 
31
+ /** Sticky page-chrome offset, applied two ways from this ONE constant:
32
+ *
33
+ * 1. As `scrollMarginTop` inline style on the wrapper — so any
34
+ * anchor-driven or `scrollIntoView()`-driven scroll (browser
35
+ * `#hash` navigation, Tab-focus into the card) lands BELOW the
36
+ * sticky header.
37
+ * 2. As `headerOffset` passed to `scrollElementIntoView(...)` — for
38
+ * the click-to-expand `window.scrollTo` path, which pre-computes
39
+ * its target pixel and ignores CSS `scroll-margin-top`.
40
+ *
41
+ * Single source of truth: change 96 here and BOTH paths follow. The
42
+ * previous code combined a `scroll-mt-24` (=96px) Tailwind class
43
+ * with this constant — two declarations, one comment binding them,
44
+ * drift hazard. Now there's nothing to keep in sync.
45
+ */
46
+ const STICKY_HEADER_OFFSET_PX = 96
47
+
29
48
  export interface HelpCenterCardProps {
30
49
  ticket: AnyTicket
31
50
  expanded: boolean
@@ -72,6 +91,39 @@ export function HelpCenterCard({
72
91
  const isExpandable = !optimistic
73
92
  const isExpanded = expanded && isExpandable
74
93
 
94
+ // Scroll-on-click — delegates to the canonical `scrollElementIntoView`
95
+ // helper with a cross-row layout-shift `adjustTargetY` callback. The
96
+ // helper owns the smooth-scroll mechanics + sticky-chrome offset; we
97
+ // pass the consumer-specific knowledge ("a sibling drawer above me
98
+ // is about to collapse — subtract its height from the target Y").
99
+ //
100
+ // Cross-row gotcha: if ANOTHER row above this one is currently
101
+ // expanded, its drawer collapses simultaneously with our toggle.
102
+ // The collapse shrinks the page above our row → our final Y is
103
+ // HIGHER than the current `rect.top`. By pre-subtracting the
104
+ // collapsing drawer's height we land at the post-shift position
105
+ // cleanly, without scrollIntoView's mid-animation drift.
106
+ const rowRef = useRef<HTMLDivElement | null>(null)
107
+ const handleClick = useCallback(() => {
108
+ onToggle(ticket.id)
109
+ scrollElementIntoView(rowRef.current, {
110
+ headerOffset: STICKY_HEADER_OFFSET_PX,
111
+ adjustTargetY: (raw) => {
112
+ if (!rowRef.current) return raw
113
+ const expandedDrawer = document.querySelector(
114
+ 'div[id^="help-center-drawer-"]',
115
+ )
116
+ if (!(expandedDrawer instanceof HTMLElement)) return raw
117
+ const drawerRect = expandedDrawer.getBoundingClientRect()
118
+ const myRect = rowRef.current.getBoundingClientRect()
119
+ // Only adjust when the drawer is ABOVE us. Drawers below us
120
+ // don't shift our position when they collapse.
121
+ if (drawerRect.bottom > myRect.top) return raw
122
+ return raw - drawerRect.height
123
+ },
124
+ })
125
+ }, [onToggle, ticket.id])
126
+
75
127
  const rightBadges = (
76
128
  <>
77
129
  <StatusBadge
@@ -93,12 +145,14 @@ export function HelpCenterCard({
93
145
 
94
146
  return (
95
147
  <div
148
+ ref={rowRef}
149
+ style={{ scrollMarginTop: STICKY_HEADER_OFFSET_PX }}
96
150
  className={`border-b border-ods-border last:border-b-0 ${optimistic ? 'opacity-60' : ''}`}
97
151
  aria-busy={optimistic || undefined}
98
152
  >
99
153
  <button
100
154
  type="button"
101
- onClick={isExpandable ? () => onToggle(ticket.id) : undefined}
155
+ onClick={isExpandable ? handleClick : undefined}
102
156
  disabled={!isExpandable}
103
157
  aria-expanded={isExpandable ? isExpanded : undefined}
104
158
  aria-controls={isExpanded ? `help-center-drawer-${ticket.id}` : undefined}
@@ -264,7 +264,15 @@ function HelpCenterListAuthed({
264
264
  />
265
265
  )
266
266
  ) : (
267
- <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
267
+ // `overflow-clip` (NOT `overflow-hidden`) — both visually
268
+ // clip the rounded corners, but `hidden` makes the element
269
+ // a "scroll container" per CSSOM spec, which causes
270
+ // `scrollIntoView` calls inside (`<HelpCenterCard>` click
271
+ // handlers) to try scrolling THIS div (can't, overflow
272
+ // hidden) instead of bubbling up to the window. `clip`
273
+ // keeps the visual clip but NOT the scroll-container
274
+ // status, so click-to-scroll actually moves the page.
275
+ <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-clip w-full">
268
276
  {merged.map((ticket) => (
269
277
  <HelpCenterCard
270
278
  key={ticket.id}
@@ -212,9 +212,16 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
212
212
  return (
213
213
  <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
214
214
  {/* Customer-authored description + any legacy `---`-joined
215
- comments. Original message gets a special role label; legacy
216
- updates get "Update N" or "Resolution". Timestamp matches the
217
- ticket's creation time when available. */}
215
+ comments. Always rendered ABOVE the engagement timeline as
216
+ "Original message" because the server's intake-burst filter
217
+ (see `filterCustomerVisibleTimeline` in
218
+ `hubspot-conversations-utils.ts`) drops the customer's first
219
+ message from engagements when it was part of the HubSpot
220
+ Custom Channel bot intake — bodyTurns IS the canonical
221
+ original for those tickets. For tickets created without bot
222
+ intake (admin-created, email channel) bodyTurns shows the
223
+ manually-entered description and engagements show subsequent
224
+ replies — same flow, no duplication. */}
218
225
  {bodyTurns.map((turn, i) => {
219
226
  const isResolution = turn.startsWith('[Resolution]')
220
227
  const role =
@@ -315,11 +322,19 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
315
322
  avatarSrc = undefined
316
323
  }
317
324
 
325
+ // Role label: every engagement is a customer-visible
326
+ // Conversations message (customer ↔ agent on the Custom
327
+ // Channel). There are no internal Notes on this surface
328
+ // anymore — the read path explicitly filters them. So
329
+ // "Reply" for BOTH sides. The previous "Note" label for
330
+ // support bubbles was a legacy artifact from when Notes
331
+ // were rendered and made customers think their support
332
+ // engineer was leaving internal comments on their ticket.
318
333
  return (
319
334
  <ConversationCardRow
320
335
  key={eng.id}
321
336
  author={author}
322
- role={isCustomer ? 'Reply' : 'Note'}
337
+ role="Reply"
323
338
  avatarSrc={avatarSrc}
324
339
  timestamp={eng.createdAt}
325
340
  body={stripAttachmentsPreamble(eng.body ?? '')}
@@ -18,7 +18,7 @@
18
18
  * rendered only when this row is the expanded one.
19
19
  */
20
20
 
21
- import { useEffect, useRef } from 'react'
21
+ import { useCallback, useRef } from 'react'
22
22
  import {
23
23
  Collapsible,
24
24
  CollapsibleContent,
@@ -28,6 +28,7 @@ import {
28
28
  type ChatTicketItemData,
29
29
  } from '../chat/entity-cards/chat-ticket-item'
30
30
  import { formatRelativeTime } from '../../utils/date-utils'
31
+ import { scrollElementIntoView } from '../../utils/scroll-into-view'
31
32
  import {
32
33
  TicketDetailDrawer,
33
34
  type TicketDetailDrawerProps,
@@ -64,25 +65,35 @@ export function TicketRow({
64
65
  // arrived yet, so action targets would be undefined.
65
66
  const optimistic = isOptimistic(ticket)
66
67
 
67
- // Scroll the row's summary tile to the top of the viewport when the
68
- // user expands it. Without this, expanding a row near the bottom of
69
- // the viewport leaves most of the drawer (timeline + composer)
70
- // hidden — the user has to scroll manually to see what they just
71
- // opened.
68
+ // Scroll the clicked card to the top of the viewport. Every click
69
+ // scrolls first-click expansion, same-row re-click, cross-row
70
+ // switch. The clicked card lands at the top.
72
71
  //
73
- // Uses smooth-scroll + `block: 'start'` so the tile lands at the top
74
- // edge. Two RAFs let the Collapsible's height animation start before
75
- // we measure the row position, otherwise the scroll lands at the
76
- // pre-expansion position.
72
+ // Cross-row gotcha: if ANOTHER row above this one is currently
73
+ // expanded, its drawer is about to collapse simultaneously with our
74
+ // toggle. We pre-subtract its height from the target Y so the
75
+ // smooth-scroll lands at the FINAL post-collapse position cleanly.
76
+ // Same pattern as `<HelpCenterCard>` — the only diff is the drawer
77
+ // id prefix (`ticket-drawer-` vs `help-center-drawer-`).
77
78
  const rowRef = useRef<HTMLDivElement | null>(null)
78
- useEffect(() => {
79
- if (!expanded || optimistic) return
80
- requestAnimationFrame(() => {
81
- requestAnimationFrame(() => {
82
- rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
83
- })
79
+ const handleClick = useCallback(() => {
80
+ onToggle(ticket.id)
81
+ scrollElementIntoView(rowRef.current, {
82
+ adjustTargetY: (raw) => {
83
+ if (!rowRef.current) return raw
84
+ const expandedDrawer = document.querySelector(
85
+ 'div[id^="ticket-drawer-"]',
86
+ )
87
+ if (!(expandedDrawer instanceof HTMLElement)) return raw
88
+ const drawerRect = expandedDrawer.getBoundingClientRect()
89
+ const myRect = rowRef.current.getBoundingClientRect()
90
+ // Only adjust when the drawer is ABOVE us. Drawers below
91
+ // don't shift our position when they collapse.
92
+ if (drawerRect.bottom > myRect.top) return raw
93
+ return raw - drawerRect.height
94
+ },
84
95
  })
85
- }, [expanded, optimistic])
96
+ }, [onToggle, ticket.id])
86
97
 
87
98
  const tileData: ChatTicketItemData = {
88
99
  id: ticket.id,
@@ -112,14 +123,14 @@ export function TicketRow({
112
123
  }
113
124
 
114
125
  return (
115
- <div ref={rowRef} className="scroll-mt-4">
126
+ <div ref={rowRef} className="scroll-mt-24">
116
127
  <Collapsible
117
128
  open={expanded && !optimistic}
118
129
  className="border-b border-ods-border last:border-b-0"
119
130
  >
120
131
  <ChatTicketItem
121
132
  ticket={tileData}
122
- onClick={optimistic ? undefined : onToggle}
133
+ onClick={optimistic ? undefined : handleClick}
123
134
  aria-expanded={expanded && !optimistic}
124
135
  aria-controls={`ticket-drawer-${ticket.id}`}
125
136
  />
@@ -62,12 +62,15 @@ export interface ChatRuntime {
62
62
  * relative `/api/storage/view/chat-attachments/` is sufficient
63
63
  * (same-origin); embedders supply an absolute hub URL so the
64
64
  * browser can fetch cross-origin.
65
- * - `chatIdentityUrl` — GET endpoint the `useChatIdentity` hook
65
+ * - `identityUrl` — GET endpoint the `useChatIdentity` hook
66
66
  * hits to learn the `{authTier, source, attachmentsEnabled}`
67
- * capability bag for the current session. */
67
+ * capability bag for the current session. Used beyond chat
68
+ * (tickets / contact form / any embedded surface that needs
69
+ * to identify the proxied customer), so the name has no
70
+ * "chat" prefix even though the consuming hook still does. */
68
71
  attachmentUploadUrl: string
69
72
  attachmentViewUrlPrefix: string
70
- chatIdentityUrl: string
73
+ identityUrl: string
71
74
  /** Optional URL prefix for the image proxy (`<prefix>?url=<external>`).
72
75
  * When unset, lib's `getProxiedImageUrl` returns the original URL
73
76
  * unchanged. Hub default: '/api/image-proxy'. Embedders that don't
package/src/nats/index.ts CHANGED
@@ -1 +1,4 @@
1
1
  export * from './nats'
2
+ export * from './shared-connection'
3
+ export * from './nats-provider'
4
+ export * from './use-nats-subscription'
@@ -0,0 +1,146 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { NatsClient, NatsStatus } from './nats'
5
+ import {
6
+ acquireClient,
7
+ releaseClient,
8
+ startConnectionLifecycle,
9
+ type AcquireClientOptions,
10
+ type NatsReconnectionBackoff,
11
+ } from './shared-connection'
12
+
13
+ export type { NatsReconnectionBackoff } from './shared-connection'
14
+
15
+ export interface NatsProviderProps {
16
+ children: React.ReactNode
17
+ /** Return the current NATS WebSocket URL (or null when not yet available, e.g. unauthenticated). */
18
+ getWsUrl: () => string | null
19
+ /** Called before each reconnect attempt. */
20
+ onBeforeReconnect?: () => Promise<void> | void
21
+ clientConfig?: AcquireClientOptions
22
+ reconnectionBackoff?: NatsReconnectionBackoff
23
+ /**
24
+ * Bump this to force re-evaluating `getWsUrl()` (e.g. when auth state flips).
25
+ * Provider does not subscribe to external auth state by itself.
26
+ */
27
+ urlRevision?: unknown
28
+ }
29
+
30
+ export interface NatsContextValue {
31
+ client: NatsClient | null
32
+ status: NatsStatus
33
+ isReady: boolean
34
+ reconnectionCount: number
35
+ }
36
+
37
+ const NatsContext = React.createContext<NatsContextValue | null>(null)
38
+
39
+ export function NatsProvider({
40
+ children,
41
+ getWsUrl,
42
+ onBeforeReconnect,
43
+ clientConfig,
44
+ reconnectionBackoff,
45
+ urlRevision,
46
+ }: NatsProviderProps) {
47
+ const [client, setClient] = React.useState<NatsClient | null>(null)
48
+ const [status, setStatus] = React.useState<NatsStatus>('closed')
49
+ const [reconnectionCount, setReconnectionCount] = React.useState(0)
50
+
51
+ const getWsUrlRef = React.useRef(getWsUrl)
52
+ React.useEffect(() => {
53
+ getWsUrlRef.current = getWsUrl
54
+ }, [getWsUrl])
55
+
56
+ const onBeforeReconnectRef = React.useRef(onBeforeReconnect)
57
+ React.useEffect(() => {
58
+ onBeforeReconnectRef.current = onBeforeReconnect
59
+ }, [onBeforeReconnect])
60
+
61
+ const reconnectionBackoffRef = React.useRef(reconnectionBackoff)
62
+ React.useEffect(() => {
63
+ reconnectionBackoffRef.current = reconnectionBackoff
64
+ }, [reconnectionBackoff])
65
+
66
+ const clientConfigRef = React.useRef(clientConfig)
67
+ React.useEffect(() => {
68
+ clientConfigRef.current = clientConfig
69
+ }, [clientConfig])
70
+
71
+ const heldUrlRef = React.useRef<string | null>(null)
72
+ const hadConnectionBeforeRef = React.useRef(false)
73
+
74
+ React.useEffect(() => {
75
+ const wsUrl = getWsUrlRef.current()
76
+
77
+ if (!wsUrl) {
78
+ if (heldUrlRef.current) {
79
+ releaseClient(heldUrlRef.current)
80
+ heldUrlRef.current = null
81
+ setClient(null)
82
+ setStatus('closed')
83
+ }
84
+ return
85
+ }
86
+
87
+ if (heldUrlRef.current && heldUrlRef.current !== wsUrl) {
88
+ releaseClient(heldUrlRef.current)
89
+ heldUrlRef.current = null
90
+ }
91
+
92
+ const conn = acquireClient(wsUrl, clientConfigRef.current)
93
+ heldUrlRef.current = wsUrl
94
+ setClient(conn.client)
95
+ setStatus(conn.client.isConnected() ? 'connected' : 'connecting')
96
+
97
+ const lifecycle = startConnectionLifecycle({
98
+ conn,
99
+ wsUrl,
100
+ onBeforeReconnect: () => onBeforeReconnectRef.current?.(),
101
+ backoff: reconnectionBackoffRef.current,
102
+ getFreshUrl: () => getWsUrlRef.current(),
103
+ onStatusChange: (newStatus) => {
104
+ setStatus(newStatus)
105
+ if (newStatus === 'connected') {
106
+ if (hadConnectionBeforeRef.current) {
107
+ setReconnectionCount((c) => c + 1)
108
+ }
109
+ hadConnectionBeforeRef.current = true
110
+ }
111
+ },
112
+ })
113
+
114
+ return () => {
115
+ lifecycle.stop()
116
+ if (heldUrlRef.current) {
117
+ releaseClient(heldUrlRef.current)
118
+ heldUrlRef.current = null
119
+ }
120
+ setClient(null)
121
+ setStatus('closed')
122
+ }
123
+ }, [urlRevision])
124
+
125
+ const value = React.useMemo<NatsContextValue>(
126
+ () => ({
127
+ client,
128
+ status,
129
+ isReady: status === 'connected' && client !== null,
130
+ reconnectionCount,
131
+ }),
132
+ [client, status, reconnectionCount],
133
+ )
134
+
135
+ return <NatsContext.Provider value={value}>{children}</NatsContext.Provider>
136
+ }
137
+
138
+ export function useNats(): NatsContextValue {
139
+ const ctx = React.useContext(NatsContext)
140
+ if (!ctx) throw new Error('useNats must be used inside <NatsProvider>')
141
+ return ctx
142
+ }
143
+
144
+ export function useOptionalNats(): NatsContextValue | null {
145
+ return React.useContext(NatsContext)
146
+ }
package/src/nats/nats.ts CHANGED
@@ -9,6 +9,8 @@ import type {
9
9
  Subscription,
10
10
  } from 'nats.ws'
11
11
 
12
+ export type { JsMsg, Msg, Subscription } from 'nats.ws'
13
+
12
14
  export type JetStreamDeliverPolicy = 'new' | 'byStartSequence'
13
15
 
14
16
  export interface JetStreamOrderedSubscribeOptions {