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

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 (103) hide show
  1. package/dist/{chunk-SMCG2CCC.cjs → chunk-6DCKL73F.cjs} +24 -24
  2. package/dist/{chunk-SMCG2CCC.cjs.map → chunk-6DCKL73F.cjs.map} +1 -1
  3. package/dist/{chunk-QTKU6ULP.js → chunk-BVFRD34B.js} +2 -2
  4. package/dist/{chunk-CDLYRFDE.js → chunk-ENBGG2K2.js} +3767 -3610
  5. package/dist/chunk-ENBGG2K2.js.map +1 -0
  6. package/dist/{chunk-K4DFAVSO.cjs → chunk-G2HHSZ3S.cjs} +9 -9
  7. package/dist/{chunk-K4DFAVSO.cjs.map → chunk-G2HHSZ3S.cjs.map} +1 -1
  8. package/dist/{chunk-2V4SACHE.js → chunk-L6IBKPVM.js} +2 -2
  9. package/dist/{chunk-572WQWIX.cjs → chunk-MVQ3OODK.cjs} +9 -9
  10. package/dist/{chunk-572WQWIX.cjs.map → chunk-MVQ3OODK.cjs.map} +1 -1
  11. package/dist/{chunk-GVNQAGXB.js → chunk-N5IKPYRL.js} +3 -81
  12. package/dist/chunk-N5IKPYRL.js.map +1 -0
  13. package/dist/{chunk-VC3ND5RB.js → chunk-SWZUZYWR.js} +2 -2
  14. package/dist/{chunk-IH76P5R6.cjs → chunk-TYIBMDUZ.cjs} +8 -86
  15. package/dist/chunk-TYIBMDUZ.cjs.map +1 -0
  16. package/dist/{chunk-ZGTDUPTW.cjs → chunk-YWDC5BXM.cjs} +382 -225
  17. package/dist/chunk-YWDC5BXM.cjs.map +1 -0
  18. package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
  19. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  20. package/dist/components/chat/chat-input.d.ts.map +1 -1
  21. package/dist/components/chat/chat-message-row.d.ts +25 -0
  22. package/dist/components/chat/chat-message-row.d.ts.map +1 -0
  23. package/dist/components/chat/index.cjs +6 -2
  24. package/dist/components/chat/index.cjs.map +1 -1
  25. package/dist/components/chat/index.d.ts +1 -0
  26. package/dist/components/chat/index.d.ts.map +1 -1
  27. package/dist/components/chat/index.js +5 -1
  28. package/dist/components/chat/types/component.types.d.ts +8 -1
  29. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  30. package/dist/components/contact/index.cjs +3 -3
  31. package/dist/components/contact/index.js +2 -2
  32. package/dist/components/features/index.cjs +2 -2
  33. package/dist/components/features/index.js +1 -1
  34. package/dist/components/form.d.ts +1 -1
  35. package/dist/components/form.d.ts.map +1 -1
  36. package/dist/components/index.cjs +56 -52
  37. package/dist/components/index.cjs.map +1 -1
  38. package/dist/components/index.js +9 -5
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/navigation/index.cjs +2 -2
  41. package/dist/components/navigation/index.js +1 -1
  42. package/dist/components/onboarding-guides/index.cjs +18 -18
  43. package/dist/components/onboarding-guides/index.js +3 -3
  44. package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
  45. package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
  46. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
  47. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  48. package/dist/components/tickets/help-center-list.d.ts.map +1 -1
  49. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
  50. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  51. package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
  52. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  53. package/dist/components/tickets/index.cjs +294 -255
  54. package/dist/components/tickets/index.cjs.map +1 -1
  55. package/dist/components/tickets/index.d.ts +1 -0
  56. package/dist/components/tickets/index.d.ts.map +1 -1
  57. package/dist/components/tickets/index.js +360 -321
  58. package/dist/components/tickets/index.js.map +1 -1
  59. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  60. package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
  61. package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
  62. package/dist/components/tickets/types.d.ts +13 -0
  63. package/dist/components/tickets/types.d.ts.map +1 -1
  64. package/dist/components/ui/index.cjs +6 -2
  65. package/dist/components/ui/index.cjs.map +1 -1
  66. package/dist/components/ui/index.js +5 -1
  67. package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
  68. package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
  69. package/dist/index.cjs +6 -2
  70. package/dist/index.cjs.map +1 -1
  71. package/dist/index.js +5 -1
  72. package/dist/utils/index.cjs +59 -4
  73. package/dist/utils/index.cjs.map +1 -1
  74. package/dist/utils/index.js +59 -4
  75. package/dist/utils/index.js.map +1 -1
  76. package/dist/utils/scroll-into-view.d.ts +43 -48
  77. package/dist/utils/scroll-into-view.d.ts.map +1 -1
  78. package/package.json +1 -1
  79. package/src/components/chat/chat-attachment-bar.tsx +58 -22
  80. package/src/components/chat/chat-input.tsx +68 -29
  81. package/src/components/chat/chat-message-row.tsx +124 -0
  82. package/src/components/chat/index.ts +1 -0
  83. package/src/components/chat/types/component.types.ts +8 -1
  84. package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
  85. package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
  86. package/src/components/tickets/help-center-card.tsx +26 -29
  87. package/src/components/tickets/help-center-list.tsx +57 -10
  88. package/src/components/tickets/hooks/use-ticket-engagements.ts +17 -1
  89. package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
  90. package/src/components/tickets/index.ts +4 -0
  91. package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
  92. package/src/components/tickets/ticket-reply-composer.tsx +195 -0
  93. package/src/components/tickets/types.ts +14 -0
  94. package/src/components/ui/ticket-attachments-list.tsx +26 -8
  95. package/src/styles/app-globals.css +13 -0
  96. package/src/utils/scroll-into-view.ts +127 -53
  97. package/dist/chunk-CDLYRFDE.js.map +0 -1
  98. package/dist/chunk-GVNQAGXB.js.map +0 -1
  99. package/dist/chunk-IH76P5R6.cjs.map +0 -1
  100. package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
  101. /package/dist/{chunk-QTKU6ULP.js.map → chunk-BVFRD34B.js.map} +0 -0
  102. /package/dist/{chunk-2V4SACHE.js.map → chunk-L6IBKPVM.js.map} +0 -0
  103. /package/dist/{chunk-VC3ND5RB.js.map → chunk-SWZUZYWR.js.map} +0 -0
@@ -18,15 +18,14 @@
18
18
  * Pair with `DevCardRowSkeletonList` for the loading state — the
19
19
  * skeleton mirrors the same min-heights so the in-flight UI doesn't
20
20
  * shift the layout when real data lands.
21
+ *
22
+ * NOTE: the ticket conversation row is NOT here — it renders the shared
23
+ * `<ChatMessageRow>` (`components/chat/chat-message-row.tsx`), the SAME
24
+ * component the OpenMSP Slack-community feed uses, so the two surfaces stay
25
+ * pixel-identical by construction.
21
26
  */
22
27
 
23
28
  import type { ReactNode } from 'react';
24
- import { SquareAvatar } from '../../ui/square-avatar';
25
- import {
26
- TicketAttachmentsList,
27
- type TicketAttachment,
28
- } from '../../ui/ticket-attachments-list';
29
- import { formatRelativeTime } from '../../../utils/date-utils';
30
29
 
31
30
  export interface DevCardRowContentProps {
32
31
  title: string;
@@ -74,183 +73,6 @@ export function DevCardRowContent({
74
73
  );
75
74
  }
76
75
 
77
- // ────────────────────────────────────────────────────────────────────────────
78
- // ConversationCardRow — sibling row variant for thread-style conversation
79
- // surfaces (ticket detail drawer, future inbox replies, etc.). Shares the
80
- // outer chrome (`border-b last:border-b-0 p-[12px] md:p-[16px]`) with
81
- // `DevCardRowContent` so a list mixing both variants stays visually
82
- // coherent. Internal layout differs because the data shape is different:
83
- // instead of title/subtitle/description + right-stacked badges, a
84
- // conversation message has an author (avatar + name + role + timestamp)
85
- // and a free-form body that should NOT be line-clamped, plus optional
86
- // attachments rendered via the shared `<TicketAttachmentsList>` so
87
- // download UX is identical to any other attachments surface in the lib.
88
- //
89
- // 2026 conversation-UI best practices applied (UXPin / Salesforce UX /
90
- // Coveo support-ticket research):
91
- // - Author identity visible on every turn (avatar + name + role chip)
92
- // - Threaded single-side layout — best for async business support,
93
- // not alternating bubbles
94
- // - Relative timestamp right-aligned, absolute on hover (`title` attr)
95
- // - Body uses `whitespace-pre-wrap break-words` so multi-line replies
96
- // and long URLs render without clipping
97
- // - WCAG 2.2 contrast + 44×44 touch targets enforced by the shared
98
- // `<TicketAttachmentsList>` download button
99
- // ────────────────────────────────────────────────────────────────────────────
100
-
101
- export interface ConversationCardRowProps {
102
- /** Display name of the message author. "You" for the current customer,
103
- * "Support team" for any non-customer engagement. */
104
- author: string;
105
- /** Optional short role label rendered as an inline chip beside the
106
- * author name — e.g. "You", "Original message", "Resolution". Keeps
107
- * the header line scannable on long threads. */
108
- role?: string;
109
- /** Avatar image URL. Falls back to `author` initials when missing
110
- * (initials derived by `<SquareAvatar>` via `getFirstLastInitials`). */
111
- avatarSrc?: string;
112
- /** ISO timestamp. Renders via `formatRelativeTime` with the absolute
113
- * string in the `title` for hover-precision. `null`/`undefined`
114
- * hides the timestamp entirely (e.g. the original ticket body which
115
- * shares the ticket's `created_at`). */
116
- timestamp?: string | null;
117
- /** Free-form message body. Empty string + zero attachments renders
118
- * nothing (the row is skipped at the caller level). */
119
- body: string;
120
- /** Files attached to this message. Rendered through the lib's
121
- * `<TicketAttachmentsList>` so the chip styling, file-icon picker
122
- * and download button match every other attachments surface. */
123
- attachments?: TicketAttachment[];
124
- /** Author bucket — kept for semantic markup + future styling needs.
125
- * Does NOT drive avatar color anymore: the avatar always renders
126
- * with the canonical `<SquareAvatar>` defaults (ODS palette,
127
- * derived by the component itself). Adding bespoke bg-color
128
- * overrides per role drifted from the ODS theme and was reverted. */
129
- variant?: 'current-user' | 'support';
130
- }
131
-
132
- export function ConversationCardRow({
133
- author,
134
- role,
135
- avatarSrc,
136
- timestamp,
137
- body,
138
- attachments,
139
- variant = 'support',
140
- }: ConversationCardRowProps) {
141
- const hasBody = body.trim().length > 0;
142
- const hasAttachments = !!attachments && attachments.length > 0;
143
- if (!hasBody && !hasAttachments) return null;
144
-
145
- const relativeTime = timestamp ? formatRelativeTime(timestamp) : null;
146
-
147
- return (
148
- <article
149
- className="border-b border-ods-border last:border-b-0 p-[12px] md:p-[16px] flex gap-[12px] md:gap-[16px] w-full"
150
- aria-label={`${author}${relativeTime ? ` · ${relativeTime}` : ''}`}
151
- >
152
- {/* Avatar — canonical `<SquareAvatar>` with NO className override.
153
- Color, border, and fallback styling all come from the ODS
154
- theme defaults (`bg-ods-bg` + `border-ods-border` +
155
- `text-ods-text-primary`). Per-role bespoke colors
156
- (bg-ods-flamingo-pink for customer / bg-ods-flamingo-cyan
157
- for support) were tried + reverted — they drifted from the
158
- standard theme and broke parity with other surfaces that
159
- use SquareAvatar without overrides (assignee-dropdown,
160
- ticket-info-section). */}
161
- <SquareAvatar
162
- src={avatarSrc}
163
- alt={author}
164
- fallback={author}
165
- size="sm"
166
- variant="round"
167
- />
168
-
169
- <div className="flex-1 min-w-0 flex flex-col gap-[8px] md:gap-[12px]">
170
- {/* Header row — author + optional role chip on the left,
171
- relative time on the right. The header collapses cleanly on
172
- narrow viewports by wrapping. */}
173
- <div className="flex items-baseline justify-between gap-[8px] flex-wrap">
174
- <div className="flex items-baseline gap-[8px] min-w-0">
175
- <h3 className="text-h4 text-ods-text-primary truncate">{author}</h3>
176
- {role && (
177
- <span className="text-h6 text-ods-text-secondary uppercase tracking-[-0.28px] shrink-0">
178
- {role}
179
- </span>
180
- )}
181
- </div>
182
- {relativeTime && (
183
- <time
184
- className="text-h6 text-ods-text-secondary uppercase tracking-[-0.28px] shrink-0"
185
- dateTime={timestamp ?? undefined}
186
- title={timestamp ?? undefined}
187
- >
188
- {relativeTime}
189
- </time>
190
- )}
191
- </div>
192
-
193
- {/* Body — full message, no line-clamp. `pre-wrap` preserves
194
- authored line breaks; `break-words` handles long URLs. */}
195
- {hasBody && (
196
- <p className="text-h4 text-ods-text-primary whitespace-pre-wrap break-words">
197
- {body}
198
- </p>
199
- )}
200
-
201
- {/* Attachments — delegated to the canonical lib component so
202
- every file-attachment surface (chat, drawer, future inbox)
203
- shares the same chip styling + download UX. */}
204
- {hasAttachments && <TicketAttachmentsList attachments={attachments!} />}
205
- </div>
206
- </article>
207
- );
208
- }
209
-
210
- /**
211
- * Skeleton variant matching `ConversationCardRow`'s layout. Used by
212
- * the ticket-detail-drawer's timeline panel while engagements load —
213
- * mirrors avatar + header + body so the loading→loaded swap doesn't
214
- * reshape the row vertically.
215
- */
216
- export function ConversationCardRowSkeleton() {
217
- return (
218
- <div className="border-b border-ods-border last:border-b-0 p-[12px] md:p-[16px] flex gap-[12px] md:gap-[16px] w-full">
219
- {/* Avatar — matches SquareAvatar size="sm" (32px) used by the
220
- real row. Round to match `variant="round"`. */}
221
- <div className="h-8 w-8 shrink-0 rounded-full bg-ods-border animate-pulse" />
222
- <div className="flex-1 min-w-0 flex flex-col gap-[8px] md:gap-[12px]">
223
- {/* Header row — author + role chip + timestamp placeholders. */}
224
- <div className="flex items-baseline justify-between gap-[8px]">
225
- <div className="flex items-baseline gap-[8px] flex-1">
226
- <div className="h-[24px] w-32 bg-ods-border rounded animate-pulse" />
227
- <div className="h-[20px] w-16 bg-ods-border rounded animate-pulse" />
228
- </div>
229
- <div className="h-[20px] w-20 bg-ods-border rounded animate-pulse shrink-0" />
230
- </div>
231
- {/* Body — two-line placeholder. */}
232
- <div className="space-y-2">
233
- <div className="h-[20px] w-full bg-ods-border rounded animate-pulse" />
234
- <div className="h-[20px] w-3/4 bg-ods-border rounded animate-pulse" />
235
- </div>
236
- </div>
237
- </div>
238
- );
239
- }
240
-
241
- /** Multi-row skeleton list — drop-in for the conversation timeline's
242
- * loading state. Defaults to 2 rows so the placeholder fits in a
243
- * reasonable vertical footprint without dominating the drawer. */
244
- export function ConversationCardRowSkeletonList({ rows = 2 }: { rows?: number }) {
245
- return (
246
- <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
247
- {Array.from({ length: rows }, (_, i) => (
248
- <ConversationCardRowSkeleton key={i} />
249
- ))}
250
- </div>
251
- );
252
- }
253
-
254
76
  /**
255
77
  * Skeleton rendering for a single row — the bars mirror the same
256
78
  * min-heights as `DevCardRowContent` so the loading→loaded swap
@@ -86,7 +86,11 @@ export function useLegalDocs(
86
86
  setData(result);
87
87
  } catch (err) {
88
88
  const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
89
- console.error(`Error fetching ${docType} document:`, err);
89
+ // `docType` is externally controlled (URL path segment in embedders), so it must NOT sit in
90
+ // console.error's FIRST (format-string) argument — Node interprets %s/%j/%o there
91
+ // (CodeQL js/tainted-format-string). Keep the format string constant; pass docType + err as
92
+ // plain trailing args.
93
+ console.error('Error fetching legal document:', docType, err);
90
94
  setError(errorMessage);
91
95
  } finally {
92
96
  setIsLoading(false);
@@ -15,7 +15,7 @@
15
15
  * is a SIBLING of the toggle button, not nested inside it.
16
16
  */
17
17
 
18
- import { useCallback, useRef } from 'react'
18
+ import { useCallback, useEffect, useRef } from 'react'
19
19
  import { StatusBadge, type StatusBadgeProps } from '../ui'
20
20
  import { formatRelativeTime } from '../../utils/date-utils'
21
21
  import { scrollElementIntoView } from '../../utils/scroll-into-view'
@@ -97,39 +97,36 @@ export function HelpCenterCard({
97
97
  const isExpandable = !optimistic
98
98
  const isExpanded = expanded && isExpandable
99
99
 
100
- // Scroll-on-click — delegates to the canonical `scrollElementIntoView`
101
- // helper with a cross-row layout-shift `adjustTargetY` callback. The
102
- // helper owns the smooth-scroll mechanics + sticky-chrome offset; we
103
- // pass the consumer-specific knowledge ("a sibling drawer above me
104
- // is about to collapse — subtract its height from the target Y").
105
- //
106
- // Cross-row gotcha: if ANOTHER row above this one is currently
107
- // expanded, its drawer collapses simultaneously with our toggle.
108
- // The collapse shrinks the page above our row → our final Y is
109
- // HIGHER than the current `rect.top`. By pre-subtracting the
110
- // collapsing drawer's height we land at the post-shift position
111
- // cleanly, without scrollIntoView's mid-animation drift.
112
100
  const rowRef = useRef<HTMLDivElement | null>(null)
101
+ // Click only toggles — the scroll-to-top is deferred to the effect below.
113
102
  const handleClick = useCallback(() => {
114
103
  onToggle(ticket.id)
115
- scrollElementIntoView(rowRef.current, {
116
- headerOffset: STICKY_HEADER_OFFSET_PX,
117
- adjustTargetY: (raw) => {
118
- if (!rowRef.current) return raw
119
- const expandedDrawer = document.querySelector(
120
- 'div[id^="help-center-drawer-"]',
121
- )
122
- if (!(expandedDrawer instanceof HTMLElement)) return raw
123
- const drawerRect = expandedDrawer.getBoundingClientRect()
124
- const myRect = rowRef.current.getBoundingClientRect()
125
- // Only adjust when the drawer is ABOVE us. Drawers below us
126
- // don't shift our position when they collapse.
127
- if (drawerRect.bottom > myRect.top) return raw
128
- return raw - drawerRect.height
129
- },
130
- })
131
104
  }, [onToggle, ticket.id])
132
105
 
106
+ // Smooth-scroll the row to the top once the drawer has expanded — in an
107
+ // effect keyed on `isExpanded` (NOT the click handler, which runs before
108
+ // React commits the drawer, when the page isn't yet tall enough to scroll).
109
+ //
110
+ // The cancellation-proof motion lives in the shared `scrollElementIntoView`
111
+ // helper (self-driven rAF tween, instant per-frame writes, target recomputed
112
+ // each frame). It is immune to the browser SCROLL ANCHORING that cancelled the
113
+ // old native `window.scrollTo({behavior:'smooth'})` on every open after the
114
+ // first — the bug where smooth "only worked once" because anchoring is
115
+ // suppressed at scrollY=0 (first open) but aborts the native smooth scroll
116
+ // from any non-zero offset (every later open). See that util for the full
117
+ // mechanics. One leading rAF so the expanded drawer has committed its height
118
+ // before the first measurement; the tween then tracks the row to its resting
119
+ // position as the page finishes growing. Cleanup cancels on collapse/unmount.
120
+ useEffect(() => {
121
+ if (!isExpanded) return
122
+ const raf = requestAnimationFrame(() => {
123
+ scrollElementIntoView(rowRef.current, {
124
+ headerOffset: STICKY_HEADER_OFFSET_PX,
125
+ })
126
+ })
127
+ return () => cancelAnimationFrame(raf)
128
+ }, [isExpanded])
129
+
133
130
  const rightBadges = (
134
131
  <>
135
132
  <StatusBadge
@@ -41,7 +41,7 @@ import { useTicketActions } from './hooks/use-ticket-actions'
41
41
  import { HelpCenterCard } from './help-center-card'
42
42
  import { HelpCenterCreateForm, HelpCenterCreateFormSkeleton } from './help-center-create-form'
43
43
  import type { AnyTicket, OptimisticTicket, TicketsCacheSlot } from './types'
44
- import { isOptimistic } from './types'
44
+ import { isOptimistic, TICKET_LIVE_POLL_MS } from './types'
45
45
 
46
46
  export interface HelpCenterListProps {
47
47
  /** Toast override (test-friendly). Defaults to the lib's shared
@@ -57,6 +57,10 @@ export function HelpCenterList({ toast = defaultToast }: HelpCenterListProps = {
57
57
 
58
58
  const search = searchParams.get('search') || ''
59
59
  const status = searchParams.get('status') || 'all'
60
+ // Deep-link: `?ticket=<external_id>` auto-opens that ticket's drawer on load.
61
+ // Same GET-param plumbing as `?search=` — read here, drilled to the authed
62
+ // child which expands the matching row once it's in the fetched list.
63
+ const ticketParam = searchParams.get('ticket') || ''
60
64
  // 1-based page from the URL. `<UnifiedPagination>` writes `?page=N`
61
65
  // on navigation; we read it here and re-fetch on change. Invalid
62
66
  // values fall back to page 1.
@@ -108,6 +112,7 @@ export function HelpCenterList({ toast = defaultToast }: HelpCenterListProps = {
108
112
  search={search}
109
113
  status={status}
110
114
  page={page}
115
+ ticketParam={ticketParam}
111
116
  searchParams={searchParams}
112
117
  router={router}
113
118
  pathname={pathname}
@@ -122,6 +127,8 @@ interface AuthedProps {
122
127
  search: string
123
128
  status: string
124
129
  page: number
130
+ /** `?ticket=<external_id>` deep-link target — auto-opens that drawer. */
131
+ ticketParam: string
125
132
  searchParams: ReturnType<typeof useSearchParams>
126
133
  router: ReturnType<typeof useRouter>
127
134
  pathname: string
@@ -134,6 +141,7 @@ function HelpCenterListAuthed({
134
141
  search,
135
142
  status,
136
143
  page,
144
+ ticketParam,
137
145
  searchParams,
138
146
  router,
139
147
  pathname,
@@ -142,6 +150,26 @@ function HelpCenterListAuthed({
142
150
  sessionEmail,
143
151
  }: AuthedProps) {
144
152
  const queryClient = useQueryClient()
153
+ const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([])
154
+ const [supportSystemDown, setSupportSystemDown] = useState(false)
155
+
156
+ // SINGLE source of truth for "which ticket is open" = the `?ticket=<external_id>`
157
+ // URL param (same model as `?search=` / `?status=`). Click-to-open and the
158
+ // deep-link path are now ONE code path: a click writes the param, the drawer's
159
+ // open state is DERIVED from the param. No separate `expandedTicketId` state,
160
+ // no auto-open effect, no re-open guard — opening, closing, deep-linking, and
161
+ // sharing a URL all flow through the same param.
162
+ const setOpenTicket = useCallback(
163
+ (externalId: string | null) => {
164
+ const params = new URLSearchParams(searchParams.toString())
165
+ if (externalId) params.set('ticket', externalId)
166
+ else params.delete('ticket')
167
+ const qs = params.toString()
168
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
169
+ },
170
+ [searchParams, router, pathname],
171
+ )
172
+
145
173
  const { tickets, isLoading, isFetching, error, refetch, totalPages } = useTicketsList({
146
174
  // `sessionEmail` is drilled in from the parent — see the same
147
175
  // pattern + race-cause rationale documented in
@@ -153,11 +181,19 @@ function HelpCenterListAuthed({
153
181
  search,
154
182
  status,
155
183
  page,
184
+ // Live status: while a drawer is open, poll so an out-of-band HubSpot
185
+ // status change (e.g. agent closes the ticket) flips the badge +
186
+ // open/reopen affordance within one interval. Idle (no drawer) → no poll.
187
+ // `ticketParam` (the open ticket's external_id) is the open signal.
188
+ refetchInterval: ticketParam ? TICKET_LIVE_POLL_MS : false,
156
189
  })
157
190
 
158
- const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([])
159
- const [expandedTicketId, setExpandedTicketId] = useState<string | null>(null)
160
- const [supportSystemDown, setSupportSystemDown] = useState(false)
191
+ // Open state DERIVED from the URL param. `?ticket=` carries the user-facing
192
+ // `external_id`; map it to the internal row id the card matches on. Resolves
193
+ // to null until the ticket lands in the fetched list (deep-link cold load) and
194
+ // auto-collapses if the open ticket disappears (e.g. TICKET_NOT_FOUND removal).
195
+ const expandedTicketId =
196
+ (ticketParam && tickets.find((t) => t.external_id === ticketParam)?.id) || null
161
197
 
162
198
  // Optimistic cache management. Kept LOCAL (not in the query cache) so
163
199
  // a refetch (e.g. URL-filter change) doesn't blow away pending
@@ -168,7 +204,8 @@ function HelpCenterListAuthed({
168
204
  }, [])
169
205
  const removeOptimistic = useCallback((placeholderId: string) => {
170
206
  setOptimisticTickets((prev) => prev.filter((t) => t.id !== placeholderId))
171
- setExpandedTicketId((prev) => (prev === placeholderId ? null : prev))
207
+ // No drawer-collapse needed: optimistic placeholders have no `external_id`,
208
+ // so they can never be the URL-derived open ticket.
172
209
  }, [])
173
210
  const removeTicketFromCache = useCallback(
174
211
  (ticketId: string) => {
@@ -191,7 +228,8 @@ function HelpCenterListAuthed({
191
228
  return { ...prev, tickets: nextTickets }
192
229
  },
193
230
  )
194
- setExpandedTicketId((prev) => (prev === ticketId ? null : prev))
231
+ // The drawer auto-collapses on its own: once the ticket leaves the list,
232
+ // the URL-derived `expandedTicketId` finds no match → null. No state to clear.
195
233
  },
196
234
  [queryClient],
197
235
  )
@@ -204,9 +242,18 @@ function HelpCenterListAuthed({
204
242
  onSupportSystemDown: () => setSupportSystemDown(true),
205
243
  })
206
244
 
207
- const toggleRow = useCallback((id: string) => {
208
- setExpandedTicketId((prev) => (prev === id ? null : id))
209
- }, [])
245
+ // Toggle = write the URL param (open) or clear it (close). The clicked card's
246
+ // internal id maps to its `external_id` for the param; optimistic rows (no
247
+ // external_id) aren't expandable so they short-circuit. This is the ONE open
248
+ // path — a click, a deep link, and a shared URL are indistinguishable.
249
+ const toggleRow = useCallback(
250
+ (id: string) => {
251
+ const t = tickets.find((x) => x.id === id)
252
+ if (!t?.external_id) return
253
+ setOpenTicket(t.external_id === ticketParam ? null : t.external_id)
254
+ },
255
+ [tickets, ticketParam, setOpenTicket],
256
+ )
210
257
 
211
258
  const merged: AnyTicket[] = [...optimisticTickets, ...tickets]
212
259
  const hasActiveFilters = search !== '' || (status !== '' && status !== 'all')
@@ -296,7 +343,7 @@ function HelpCenterListAuthed({
296
343
  onSendMessage={actions.sendMessage}
297
344
  onClose={actions.closeTicket}
298
345
  onReopen={actions.reopenTicket}
299
- onActionCollapsed={() => setExpandedTicketId(null)}
346
+ onActionCollapsed={() => setOpenTicket(null)}
300
347
  replyError={actions.replyErrorFor(ticket.external_id)}
301
348
  onClearReplyError={() => actions.clearReplyError(ticket.external_id)}
302
349
  />
@@ -73,7 +73,18 @@ export interface UseTicketEngagementsReturn {
73
73
  refetch: () => void
74
74
  }
75
75
 
76
- export function useTicketEngagements(externalTicketId: string | null | undefined, enabled = true): UseTicketEngagementsReturn {
76
+ export function useTicketEngagements(
77
+ externalTicketId: string | null | undefined,
78
+ enabled = true,
79
+ /** Poll cadence (ms) for live conversation refresh while the drawer is
80
+ * open. The drawer only mounts this hook when expanded, so a constant
81
+ * here is already gated to "drawer open" — closing the drawer unmounts
82
+ * the panel and the polling stops. `false`/undefined disables it (the
83
+ * default, preserving prior fetch-once-per-open behavior for any other
84
+ * caller). Mirrors `useTicketsList.refetchInterval`; see
85
+ * `TICKET_LIVE_POLL_MS`. */
86
+ refetchInterval: number | false = false,
87
+ ): UseTicketEngagementsReturn {
77
88
  const identity = useChatIdentity()
78
89
  const identityKey = identity.user?.email ?? 'anon'
79
90
 
@@ -94,6 +105,11 @@ export function useTicketEngagements(externalTicketId: string | null | undefined
94
105
  gcTime: 0,
95
106
  refetchOnMount: 'always',
96
107
  refetchOnWindowFocus: true,
108
+ // Live conversation: poll while the caller opts in (drawer open). New
109
+ // agent replies + attachments appear within one interval without a
110
+ // manual refresh. `refetchIntervalInBackground` stays false (default)
111
+ // so polling pauses on a hidden tab.
112
+ refetchInterval,
97
113
  queryFn: async (): Promise<TicketEngagement[]> => {
98
114
  const response = await embedAuthedFetch(LIST_ENGAGEMENTS_ENDPOINT, {
99
115
  method: 'POST',
@@ -58,6 +58,13 @@ export interface UseTicketsListFilters {
58
58
  page?: number
59
59
  /** Items per page (server caps at 100). Defaults to 20. */
60
60
  pageSize?: number
61
+ /** Poll interval (ms) for live updates — e.g. so a ticket CLOSED out-of-band
62
+ * on HubSpot flips the status badge + open/reopen affordance without a manual
63
+ * refresh. `false`/0/undefined disables polling. The hub passes a value only
64
+ * while a drawer is open (mirror webhooks keep the server fresh; this surfaces
65
+ * it client-side). TanStack pauses interval polling while the tab is hidden,
66
+ * so there are no wasted background requests. */
67
+ refetchInterval?: number | false
61
68
  }
62
69
 
63
70
  export interface UseTicketsListReturn {
@@ -103,6 +110,8 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
103
110
  // previous identity's data.
104
111
  const identityKey = customerEmail || 'anon'
105
112
 
113
+ const refetchInterval = filters.refetchInterval ?? false
114
+
106
115
  const query = useQuery({
107
116
  queryKey: ['tickets', 'self', identityKey, search, statusFilter, page, pageSize],
108
117
  enabled,
@@ -115,6 +124,10 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
115
124
  gcTime: 0,
116
125
  refetchOnMount: 'always',
117
126
  refetchOnWindowFocus: true,
127
+ // Live status: poll while the caller opts in (drawer open). Defaults to
128
+ // false. `refetchIntervalInBackground` stays false (the default) so polling
129
+ // pauses on a hidden tab — no wasted requests when the user tabs away.
130
+ refetchInterval,
118
131
  queryFn: async (): Promise<FindTicketResponse> => {
119
132
  const body: Record<string, string | number> = {
120
133
  query: search,
@@ -10,6 +10,10 @@ export {
10
10
  TicketLinkedDeliveryCard,
11
11
  type TicketLinkedDeliveryCardProps,
12
12
  } from './ticket-linked-delivery-card'
13
+ export {
14
+ TicketReplyComposer,
15
+ type TicketReplyComposerProps,
16
+ } from './ticket-reply-composer'
13
17
  // Help Center — full-page customer-facing surface used by
14
18
  // openframe's `/tickets` route. Composes `DevSectionPage` chrome
15
19
  // (hero + search + filter) + a creation form above the controls +