{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-5WHUUHSH.cjs","../src/components/tickets/ticket-center.tsx","../src/components/tickets/hooks/use-ticket-actions.ts","../src/components/tickets/hooks/use-tickets-list.ts","../src/components/tickets/ticket-open-form.tsx","../src/components/tickets/ticket-row.tsx","../src/components/collapsible.tsx","../src/components/tickets/ticket-detail-drawer.tsx","../src/components/tickets/hooks/use-ticket-engagements.ts","../src/components/tickets/ticket-linked-delivery-card.tsx","../src/components/tickets/ticket-reply-composer.tsx","../src/components/tickets/help-center-list.tsx","../src/components/tickets/help-center-card.tsx","../src/components/tickets/help-center-create-form.tsx"],"names":["toast","useState","jsxs","useCallback","jsx","CollapsibleContent","useQueryClient"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-5WHUUHSH.cjs","sourcesContent":[null,"'use client';\n\n/**\n * `<TicketCenter />` — the customer-facing ticket management surface.\n *\n * Single component the hub mounts at `/tickets` and that third-party\n * apps embed alongside `<EmbeddableChat />`. The lib intentionally does\n * NOT bundle a QueryClientProvider or ChatRuntimeContext.Provider — the\n * embedder mounts both at their app root (same pattern as\n * `<EmbeddableChat />`).\n *\n * Identity gate: if the chat-identity hook reports anon, render ONLY a\n * sign-in EmptyState — no form, no list, no fetch. This keeps the\n * Network tab clean for anon visitors and prevents the form from\n * accepting input that would 401 on submit.\n */\n\nimport { useQueryClient } from '@tanstack/react-query';\nimport { RefreshCw } from 'lucide-react';\nimport { useCallback, useState } from 'react';\nimport { toast as defaultToast } from '../../hooks/use-toast';\nimport { formatRelativeTime } from '../../utils/date-utils';\nimport { devSectionAnchorId } from '../../utils/dev-sections/dev-section-param-keys';\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity';\nimport { EmptyState } from '../empty-state';\nimport { Button } from '../ui/button';\nimport { Card } from '../ui/card';\nimport { Skeleton } from '../ui/skeleton';\nimport { useTicketActions } from './hooks/use-ticket-actions';\nimport { useTicketsList } from './hooks/use-tickets-list';\nimport { TicketOpenForm } from './ticket-open-form';\nimport { TicketRow } from './ticket-row';\nimport type { AnyTicket, OptimisticTicket, TicketData } from './types';\nimport { isOptimistic } from './types';\n\nexport interface TicketCenterProps {\n  /** Optional toast override (test-friendly). Defaults to the lib's\n   *  shared toast singleton. */\n  toast?: typeof defaultToast;\n}\n\nexport function TicketCenter({ toast = defaultToast }: TicketCenterProps = {}) {\n  const identity = useChatIdentity();\n  // Loading window — wait for the capability bag to resolve before\n  // deciding what to render. `identity.isLoading` is the first-mount\n  // window; once resolved we know authTier definitively.\n  if (identity.isLoading) {\n    return <TicketCenterSkeleton />;\n  }\n  if (identity.authTier === 'anon' || !identity.user?.email) {\n    return (\n      <EmptyState\n        type=\"generic\"\n        title=\"Sign in to manage tickets\"\n        description=\"View, open, and follow up on support tickets after signing in.\"\n        showCTA={false}\n      />\n    );\n  }\n  return <TicketCenterAuthed toast={toast} sessionEmail={identity.user.email} />;\n}\n\nfunction TicketCenterAuthed({\n  toast,\n  sessionEmail,\n}: {\n  toast: typeof defaultToast;\n  /** Identity drilled from the parent — see `useTicketsList`'s\n   *  `customerEmail` arg doc for the race-cause rationale. */\n  sessionEmail: string;\n}) {\n  const queryClient = useQueryClient();\n  const { tickets, isLoading, isFetching, refetch, lastUpdatedAt } = useTicketsList({\n    customerEmail: sessionEmail,\n  });\n  const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([]);\n  const [expandedTicketId, setExpandedTicketId] = useState<string | null>(null);\n  const [supportSystemDown, setSupportSystemDown] = useState(false);\n\n  // Optimistic cache management. Kept LOCAL (not in the query cache) so\n  // a refetch doesn't blow away pending placeholders mid-flight. The\n  // merged view is `[...optimistic, ...server]` so optimistic rows sit\n  // at the top until they're explicitly removed.\n  const prependOptimistic = useCallback((placeholder: OptimisticTicket) => {\n    setOptimisticTickets(prev => [placeholder, ...prev]);\n  }, []);\n  const removeOptimistic = useCallback((placeholderId: string) => {\n    setOptimisticTickets(prev => prev.filter(t => t.id !== placeholderId));\n    // If the parent had this temp id expanded (shouldn't happen — the\n    // drawer is hidden on optimistic rows — but defensive), null it\n    // so we don't dangle a stale id.\n    setExpandedTicketId(prev => (prev === placeholderId ? null : prev));\n  }, []);\n  const removeTicketFromCache = useCallback(\n    (ticketId: string) => {\n      // Target every cache slot under the ['tickets'] prefix — the\n      // queryKey now includes an identityKey segment (use-tickets-list)\n      // so a bare ['tickets', 'self'] write would no-op silently.\n      queryClient.setQueriesData<TicketData[] | undefined>({ queryKey: ['tickets'] }, prev =>\n        (prev ?? []).filter(t => t.id !== ticketId),\n      );\n      setExpandedTicketId(prev => (prev === ticketId ? null : prev));\n    },\n    [queryClient],\n  );\n\n  const actions = useTicketActions({\n    prependOptimistic,\n    removeOptimistic,\n    removeTicketFromCache,\n    toast,\n    onSupportSystemDown: () => setSupportSystemDown(true),\n  });\n\n  const toggleRow = useCallback((id: string) => {\n    setExpandedTicketId(prev => (prev === id ? null : id));\n  }, []);\n\n  const merged: AnyTicket[] = [...optimisticTickets, ...tickets];\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <TicketOpenForm\n        onSubmit={input => actions.submitTicket(input)}\n        isSubmitting={actions.isSubmittingForm}\n        supportSystemDown={supportSystemDown}\n      />\n\n      <div className=\"flex flex-col gap-2\">\n        <div className=\"flex items-center justify-between gap-3\">\n          <p className=\"text-ods-text-secondary text-h5\">Your Current Tickets</p>\n          <div className=\"flex items-center gap-3 text-ods-text-secondary text-h6\">\n            {lastUpdatedAt && <span>Updated {formatRelativeTime(new Date(lastUpdatedAt))}</span>}\n            <Button\n              type=\"button\"\n              variant=\"transparent\"\n              size=\"small\"\n              onClick={refetch}\n              disabled={isFetching}\n              aria-label=\"Refresh ticket list\"\n              leftIcon={<RefreshCw className=\"h-4 w-4\" />}\n            />\n          </div>\n        </div>\n\n        {isLoading ? (\n          <TicketListSkeleton />\n        ) : merged.length === 0 ? (\n          <Card className=\"p-6\">\n            <EmptyState\n              type=\"generic\"\n              title=\"No tickets yet\"\n              description=\"Open one above to start the conversation.\"\n              showCTA={false}\n            />\n          </Card>\n        ) : (\n          <Card className=\"overflow-hidden\">\n            {merged.map(ticket => (\n              <TicketRow\n                key={ticket.id}\n                id={devSectionAnchorId('ticket', ticket.external_id)}\n                ticket={ticket}\n                expanded={expandedTicketId === ticket.id}\n                onToggle={toggleRow}\n                busy={isOptimistic(ticket) ? false : actions.isRowBusy(ticket.id)}\n                supportSystemDown={supportSystemDown}\n                onSendMessage={actions.sendMessage}\n                onClose={actions.closeTicket}\n                onReopen={actions.reopenTicket}\n                onActionCollapsed={() => setExpandedTicketId(null)}\n              />\n            ))}\n          </Card>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction TicketCenterSkeleton() {\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <Card className=\"p-6\">\n        <Skeleton className=\"mb-4 h-7 w-48\" />\n        <Skeleton className=\"mb-3 h-10 w-full\" />\n        <Skeleton className=\"h-24 w-full\" />\n      </Card>\n      <TicketListSkeleton />\n    </div>\n  );\n}\n\nfunction TicketListSkeleton() {\n  return (\n    <Card className=\"overflow-hidden\">\n      {[0, 1, 2].map(i => (\n        <div key={i} className=\"flex h-20 items-center gap-4 border-b border-ods-border px-4 last:border-b-0\">\n          <div className=\"flex flex-1 flex-col gap-2\">\n            <Skeleton className=\"h-4 w-2/3\" />\n            <Skeleton className=\"h-3 w-full\" />\n          </div>\n          <Skeleton className=\"h-8 w-20\" />\n          <Skeleton className=\"h-8 w-16\" />\n        </div>\n      ))}\n    </Card>\n  );\n}\n","'use client';\n\n/**\n * All 5 ticket write actions funnel through one helper:\n * `executeTicketAction()`, which POSTs to `/api/chat/agent/ticket-action`\n * — a single-roundtrip endpoint that runs the SAME `ChatToolHandler.execute`\n * the chat agent's `confirm-tool` route uses (same ACL re-bind, same audit\n * row, same HubSpot REST call). The only difference is REST shape: the\n * chat path needs `propose → confirm-tool` because the LLM emits a\n * `tool_use` the user must approve in a proposal card. The /tickets form\n * has no approval step (the user already clicked \"Open ticket\" / \"Send\"),\n * so the two-step is pure overhead — we collapse to one POST + JSON.\n *\n * Reuses every existing piece:\n *   - `embedAuthedFetch` for the bearer/act-as headers (same auth as chat).\n *   - TanStack-Query `invalidateQueries` for refetch.\n *\n * Single-flight + serialization:\n *   - Form-level `formInFlight` ref drops second submits while one is\n *     in flight.\n *   - Per-row `Set<ticket_id>` mutex prevents fan-out on the same row.\n *   - `mutationQueue` serializes ALL mutations through a depth=1 queue\n *     so 10× \"Close\" doesn't stampede the server's auth-write 60/min\n *     rate-limit.\n *\n * Mirror-sync retry: on `result.mirror_synced === false`, the helper\n * schedules 3s/6s/12s refetches (30s wall-clock cap). If the placeholder\n * is still present after the last attempt, it's removed and the parent\n * surfaces an inline \"Couldn't confirm — Reload\" affordance.\n */\n\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport type { ChatAttachment } from '../../chat/utils/chat-attachment-markdown';\nimport { useOptionalTicketLive } from '../ticket-live-provider';\nimport {\n  type AnyTicket,\n  type MappedTicketActionError,\n  type OptimisticTicket,\n  type TicketActionErrorCode,\n  type TicketsCacheSlot,\n  TOAST_COPY,\n} from '../types';\n\nconst TICKET_ACTION_ENDPOINT = '/api/chat/agent/ticket-action';\n\n/** Codes that populate the inline reply-failure banner above the drawer\n *  composer. Other codes (system-down, ticket-gone, rate-limit) are\n *  full-row / full-system signals covered by the toast + supportSystemDown\n *  handling — surfacing them in the inline banner too would be redundant. */\nconst REPLY_BANNER_CODES: ReadonlySet<TicketActionErrorCode> = new Set<TicketActionErrorCode>([\n  'HUBSPOT_5XX',\n  'HUBSPOT_400_VALIDATION',\n  'HUBSPOT_404_THREAD',\n  'HUBSPOT_REPLY_UNKNOWN',\n]);\n\n/** 3 attempts × backoff (cumulative ~21s wall-clock). After this we\n *  drop the optimistic row and ask the user to reload. */\nconst MIRROR_SYNC_BACKOFF_MS = [3_000, 6_000, 12_000] as const;\n\ntype ToolName = 'create_ticket' | 'update_ticket';\n\n/** Wire shape returned by the new `/api/chat/agent/ticket-action` endpoint.\n *  Flat — no decision-frame wrapping — because there's no LLM approval\n *  loop on the form path. Mirrors `ExecuteResult` from\n *  `chat-source-strategy.ts` plus `{ ok, ticket_id }` at the top so\n *  callers don't need to know about the underlying `id` field. */\ninterface TicketActionResponse {\n  ok?: boolean;\n  ticket_id?: string;\n  status?: string | null;\n  mirror_synced?: boolean;\n  raw?: unknown;\n  error?: string;\n  code?: string;\n}\n\ninterface SubmitTicketInput {\n  subject: string;\n  content: string;\n  attachments?: ChatAttachment[];\n}\n\ninterface UpdateTicketArgs {\n  ticket_id: string;\n  status?: 'OPEN' | 'CLOSED';\n  content_addendum?: string;\n  resolution?: string;\n  attachments?: ChatAttachment[];\n}\n\nexport interface UseTicketActionsOptions {\n  /** Called when the parent should prepend an optimistic placeholder\n   *  to the local cache. Implementer mutates the QueryClient cache\n   *  directly so the row appears before the server roundtrip. */\n  prependOptimistic: (placeholder: OptimisticTicket) => void;\n  /** Called when the optimistic placeholder should be removed\n   *  (mirror-sync failure, or replacement after real ticket arrives). */\n  removeOptimistic: (placeholderId: string) => void;\n  /** Called when a ticket should be removed from the cache without\n   *  a refetch (TICKET_NOT_FOUND). */\n  removeTicketFromCache: (ticketId: string) => void;\n  /** Toast helper from `@flamingo-stack/openframe-frontend-core/hooks`.\n   *  Passed in so the lib doesn't import the toast singleton itself\n   *  (test-friendly). */\n  toast: (input: { title: string; description?: string; variant?: 'success' | 'destructive' | 'default' }) => void;\n  /** Called when a 412 HUBSPOT_DISCONNECTED arrives so the parent can\n   *  flip its `supportSystemDown` flag. */\n  onSupportSystemDown: () => void;\n}\n\n/**\n * Identity bundle used by every row action: the LOCAL mirror UUID\n * (drives the React-side mutex + optimistic cache removal) AND the\n * HubSpot ticket id (`external_id` — drives the server call, the only\n * id HubSpot REST recognizes). Decoupling these is mandatory: passing\n * the UUID to HubSpot gets you a 404 \"Object not found. objectId are\n * usually numeric\"; passing the external id to the React-side mutex\n * breaks per-row disable when the cache differs.\n */\nexport interface TicketRef {\n  id: string;\n  external_id: string;\n}\n\nexport interface UseTicketActionsReturn {\n  submitTicket: (input: SubmitTicketInput) => Promise<boolean>;\n  /** Single combined \"reply\" action — text + optional attachments\n   *  delivered as ONE HubSpot Note engagement (one bubble in the\n   *  timeline). Server creates a merged note when both are present. */\n  sendMessage: (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => Promise<boolean>;\n  closeTicket: (ticket: TicketRef, resolution?: string) => Promise<boolean>;\n  reopenTicket: (ticket: TicketRef) => Promise<boolean>;\n  /** `true` while the form-level submit is in flight. */\n  isSubmittingForm: boolean;\n  /** Per-row in-flight set (read-only). UI uses `isRowBusy(localId)`. */\n  isRowBusy: (localId: string) => boolean;\n  /** Most recent reply failure for a given ticket id (`external_id`).\n   *  Drives the inline \"couldn't send\" banner above the composer in\n   *  `<TicketDetailDrawer>`. Cleared on the next successful send OR\n   *  via `clearReplyError(ticketId)`. */\n  replyErrorFor: (ticketExternalId: string) => MappedTicketActionError | null;\n  /** Clear the persisted reply-failure banner for a ticket (e.g. when\n   *  the user dismisses it or starts a new draft). */\n  clearReplyError: (ticketExternalId: string) => void;\n}\n\n/** Exported so unit tests can construct an instance to exercise the\n *  per-code branches of `mapTicketActionError`. Not part of the public\n *  surface — kept out of `tickets/index.ts`. */\nexport class TicketActionFailure extends Error {\n  code: TicketActionErrorCode;\n  response?: Response;\n  constructor(code: TicketActionErrorCode, message: string, response?: Response) {\n    super(message);\n    this.code = code;\n    this.response = response;\n  }\n}\n\nexport function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {\n  const queryClient = useQueryClient();\n  // Endpoint from the runtime config (like every other endpoint); falls back to\n  // the bare hub path when unconfigured.\n  const ticketActionEndpoint = useRequiredChatRuntime().endpoints.ticketActionUrl ?? TICKET_ACTION_ENDPOINT;\n  // Optional live-stream context: a first-ever ticket must retry the\n  // provider's `no-stream` (204) subscription so replies stream in.\n  const ticketLive = useOptionalTicketLive();\n  const notifyTicketCreated = ticketLive?.notifyTicketCreated;\n  const { prependOptimistic, removeOptimistic, removeTicketFromCache, toast, onSupportSystemDown } = options;\n\n  // Form-level single-flight uses BOTH a ref (for synchronous guarding\n  // inside `submitTicket`, since React state setters are async) and a\n  // state mirror (for UI disable / loading prop). Two rapid clicks in\n  // the same tick would otherwise both see state==false and fan out\n  // duplicate propose calls.\n  // Holds the identity of the submit that owns the lock (`null` = idle) rather\n  // than a bare boolean, so the release in `submitTicket`'s `finally` — which\n  // runs after several awaits — can check that the lock it is clearing is still\n  // its own instead of clearing whatever is there now.\n  const formInFlightRef = useRef<object | null>(null);\n  const [isSubmittingForm, setIsSubmittingForm] = useState(false);\n\n  // Per-row mutex — same split: ref for synchronous has/add/delete,\n  // state for the `isRowBusy` selector that drives row disable.\n  const busyRowsRef = useRef<Set<string>>(new Set());\n  const [busyRows, setBusyRows] = useState<Set<string>>(() => new Set());\n  const setRowBusy = useCallback((id: string, busy: boolean) => {\n    if (busy) busyRowsRef.current.add(id);\n    else busyRowsRef.current.delete(id);\n    // Mirror to state (new Set so React notices). Render-only side.\n    setBusyRows(new Set(busyRowsRef.current));\n  }, []);\n  const isRowBusy = useCallback((id: string) => busyRows.has(id), [busyRows]);\n\n  // Persisted reply-failure banner state — keyed by the ticket's\n  // HubSpot `external_id`. The drawer reads `replyErrorFor(externalId)`\n  // and renders an inline \"couldn't send — retry\" banner above the\n  // composer. Cleared automatically on the next successful send and\n  // explicitly by the dismiss-X / \"Retry\" actions in the banner UI.\n  // Distinct from the transient toast — the banner persists so the\n  // user can locate their failed draft after dismissing the toast.\n  const [replyErrorByTicket, setReplyErrorByTicket] = useState<Map<string, MappedTicketActionError>>(() => new Map());\n  const setReplyError = useCallback((externalId: string, mapped: MappedTicketActionError | null) => {\n    setReplyErrorByTicket(prev => {\n      const next = new Map(prev);\n      if (mapped) next.set(externalId, mapped);\n      else next.delete(externalId);\n      return next;\n    });\n  }, []);\n  const replyErrorFor = useCallback(\n    (externalId: string): MappedTicketActionError | null => replyErrorByTicket.get(externalId) ?? null,\n    [replyErrorByTicket],\n  );\n  const clearReplyError = useCallback((externalId: string) => setReplyError(externalId, null), [setReplyError]);\n\n  // Mirror-sync watcher controllers tracked by placeholder id so we can\n  // abort prior watchers when a new submit lands AND so unmount cleans\n  // them up without leaking setState calls. Single source of truth for\n  // active watchers — never duplicate-schedule.\n  const watcherControllersRef = useRef<Map<string, AbortController>>(new Map());\n  useEffect(() => {\n    // Captured at setup rather than read through the ref in cleanup — the Map\n    // identity never changes, and the local says so.\n    const controllers = watcherControllersRef.current;\n    return () => {\n      // Component unmount — abort every live watcher so setState calls\n      // inside the scheduler don't fire on an unmounted component.\n      for (const controller of controllers.values()) {\n        controller.abort();\n      }\n      controllers.clear();\n    };\n  }, []);\n\n  // Single-flight queue (depth=1). Subsequent calls await the prior\n  // promise. Local backoff timers and SSE drains run inside the queued\n  // closure so the second user click waits for the first to fully\n  // resolve before issuing its own propose call. This is the\n  // server-stampede defense.\n  const queueRef = useRef<Promise<unknown>>(Promise.resolve());\n  const enqueue = useCallback(<T>(work: () => Promise<T>): Promise<T> => {\n    const next = queueRef.current.then(work, work);\n    // Swallow rejection from the prior step so a single failure doesn't\n    // poison every subsequent enqueue.\n    queueRef.current = next.catch(() => undefined);\n    return next;\n  }, []);\n\n  const executeTicketAction = useCallback(\n    async (toolName: ToolName, args: Record<string, unknown>): Promise<TicketActionResponse> => {\n      const res = await embedAuthedFetch(ticketActionEndpoint, {\n        method: 'POST',\n        body: JSON.stringify({ tool_name: toolName, args }),\n      });\n      // Server returns JSON for both success and failure — no SSE on this\n      // route. Parse once, branch on `res.ok`.\n      const body = (await res.json().catch(() => ({}))) as TicketActionResponse;\n      if (!res.ok) {\n        const code = resolveErrorCode(body.code, res.status);\n        const message = body.error || `${toolName} failed (${res.status})`;\n        throw new TicketActionFailure(code, message, res);\n      }\n      return body;\n    },\n    [ticketActionEndpoint],\n  );\n\n  // Mirror-sync watcher — backoff refetches when the post-create mirror\n  // upsert fails. Tracked in `watcherControllersRef` so unmount aborts\n  // every live scheduler and a duplicate submit for the same placeholder\n  // replaces the prior controller cleanly (no orphaned schedulers).\n  //\n  // `expectedTicketId` is the external_id the server returned from\n  // create_ticket. After each invalidation refetch lands, if any cache\n  // slot now contains a ticket with that id, the placeholder is dropped\n  // immediately — preventing the duplicate-row window where placeholder\n  // + real row both render until the 30s cap fires.\n  const watchMirrorSync = useCallback(\n    (placeholderId: string, expectedTicketId: string | undefined) => {\n      const prior = watcherControllersRef.current.get(placeholderId);\n      if (prior) prior.abort();\n      const controller = new AbortController();\n      watcherControllersRef.current.set(placeholderId, controller);\n      const schedule = async () => {\n        try {\n          for (let i = 0; i < MIRROR_SYNC_BACKOFF_MS.length; i++) {\n            if (controller.signal.aborted) return;\n            await new Promise<void>(resolve => {\n              const t = setTimeout(resolve, MIRROR_SYNC_BACKOFF_MS[i]);\n              controller.signal.addEventListener(\n                'abort',\n                () => {\n                  clearTimeout(t);\n                  resolve();\n                },\n                { once: true },\n              );\n            });\n            if (controller.signal.aborted) return;\n            await queryClient.invalidateQueries({ queryKey: ['tickets'] });\n            // If the real ticket landed during this refetch, drop the\n            // placeholder + stop scheduling — no duplicate-row window.\n            if (expectedTicketId && cacheContainsTicket(queryClient, expectedTicketId)) {\n              removeOptimistic(placeholderId);\n              return;\n            }\n          }\n          // Last-resort cleanup — placeholder didn't get replaced.\n          if (!controller.signal.aborted) {\n            removeOptimistic(placeholderId);\n            toast({\n              title: \"Couldn't confirm ticket\",\n              description: \"If the ticket doesn't appear shortly, please contact support.\",\n              variant: 'destructive',\n            });\n          }\n        } finally {\n          // Self-deregister on natural completion or abort so the map\n          // doesn't accrete dead controllers across many submits.\n          if (watcherControllersRef.current.get(placeholderId) === controller) {\n            watcherControllersRef.current.delete(placeholderId);\n          }\n        }\n      };\n      void schedule();\n    },\n    [queryClient, removeOptimistic, toast],\n  );\n\n  // Last `surfaceError` mapping — sendMessage reads this immediately\n  // after the catch returns so it can decide whether to populate the\n  // inline reply banner. Cleared on every read by the consumer to\n  // prevent a stale failure from leaking into the next attempt.\n  const lastUpdateErrorRef = useRef<MappedTicketActionError | null>(null);\n  const surfaceError = useCallback(\n    (err: unknown, action: string): MappedTicketActionError => {\n      const mapped = mapTicketActionError(err);\n      lastUpdateErrorRef.current = mapped;\n      if (mapped.supportSystemDown) onSupportSystemDown();\n      toast({\n        title: `Could not ${action}`,\n        description: mapped.message,\n        variant: 'destructive',\n      });\n      return mapped;\n    },\n    [toast, onSupportSystemDown],\n  );\n\n  const submitTicket = useCallback(\n    async (input: SubmitTicketInput): Promise<boolean> => {\n      // Synchronous ref guard — closes the same-tick double-click race\n      // that the state-only guard couldn't (setIsSubmittingForm is async).\n      if (formInFlightRef.current !== null) return false;\n      const submitToken: object = {};\n      formInFlightRef.current = submitToken;\n      setIsSubmittingForm(true);\n      const placeholderId = `temp-${cryptoRandomId()}`;\n      const placeholder: OptimisticTicket = {\n        id: placeholderId,\n        external_id: 'Pending sync…',\n        subject: input.subject.trim(),\n        preview: input.content.trim().slice(0, 400),\n        body: input.content.trim(),\n        status: 'OPEN',\n        pipeline_stage_label: 'New',\n        clickup_task_id: null,\n        clickup: null,\n        priority: null,\n        customer_emails: [],\n        customer_company: null,\n        // Optimistic placeholder has no resolved HubSpot contact yet\n        // — the real ticket row replaces this within a couple of\n        // seconds via the mirror refetch. Drawer uses live chat\n        // identity for own-replies during this window anyway.\n        customer_name: null,\n        // No assignee until the real ticket lands. Drawer renders\n        // \"Unassigned\" for this brief window.\n        assigned_to: null,\n        assignedOwner: null,\n        hubspot_updated_at: new Date().toISOString(),\n        _optimistic: true,\n      };\n      prependOptimistic(placeholder);\n      try {\n        return await enqueue(async () => {\n          const result = await executeTicketAction('create_ticket', {\n            subject: input.subject.trim(),\n            content: input.content.trim(),\n            ...(input.attachments?.length ? { attachments: input.attachments } : {}),\n          });\n          if (result.mirror_synced === false) {\n            toast(TOAST_COPY.open_mirror_pending);\n            watchMirrorSync(placeholderId, result.ticket_id);\n          } else {\n            toast(TOAST_COPY.open_success);\n            // Invalidate FIRST so the refetch lands before the\n            // placeholder is removed — prevents a one-tick flash of\n            // EmptyState when the prior cache was empty.\n            await queryClient.invalidateQueries({ queryKey: ['tickets'] });\n            removeOptimistic(placeholderId);\n          }\n          // A zero-ticket viewer's stream returned 204 before this\n          // create — nudge the provider to (re)subscribe.\n          notifyTicketCreated?.();\n          return true;\n        });\n      } catch (err) {\n        removeOptimistic(placeholderId);\n        surfaceError(err, 'open ticket');\n        return false;\n      } finally {\n        // Release only our own lock. The guard above makes a second submit\n        // impossible while this one runs, so today this is always true — but\n        // the check is what keeps the invariant local instead of depending on\n        // every future caller of `formInFlightRef` preserving it.\n        if (formInFlightRef.current === submitToken) {\n          formInFlightRef.current = null;\n          setIsSubmittingForm(false);\n        }\n      }\n    },\n    [\n      enqueue,\n      executeTicketAction,\n      prependOptimistic,\n      removeOptimistic,\n      queryClient,\n      toast,\n      watchMirrorSync,\n      notifyTicketCreated,\n      surfaceError,\n    ],\n  );\n\n  const updateTicket = useCallback(\n    async (\n      ticket: TicketRef,\n      serverArgs: Omit<UpdateTicketArgs, 'ticket_id'>,\n      successCopy: { title: string; description?: string },\n      action: string,\n    ): Promise<boolean> => {\n      // Mutex keyed on the LOCAL mirror id (stable across the React tree\n      // + matches the cache row's `id` for optimistic removal). Server\n      // arg uses `external_id` — HubSpot's only-numeric ticket id.\n      if (busyRowsRef.current.has(ticket.id)) return false;\n      setRowBusy(ticket.id, true);\n      try {\n        return await enqueue(async () => {\n          await executeTicketAction('update_ticket', {\n            ...serverArgs,\n            ticket_id: ticket.external_id,\n          });\n          toast(successCopy);\n\n          // OPTIMISTIC in-place row update on the tickets cache.\n          //\n          // Previously this code called\n          // `queryClient.invalidateQueries({ queryKey: ['tickets'] })`\n          // which forced a full refetch. When the user is on a\n          // filtered view (e.g. ?status=open) and CLOSES a ticket from\n          // its drawer, the refetched list excludes the now-closed\n          // row, the parent `<HelpCenterCard>` for that row unmounts,\n          // and the inline drawer dies with it — user-facing bug\n          // \"close button refreshes the whole page and dismisses the\n          // ticket I was working on\" (reported 2026-05-29).\n          //\n          // The mutation already knows what changed (status, content\n          // addendum, attachments) — apply those fields in place\n          // across every `['tickets']` cache slot. The row stays in\n          // the list with the new badge; React doesn't reconcile away\n          // the card; the drawer stays mounted and the user can\n          // continue working.\n          //\n          // Filter-mismatch trade-off: a row that no longer matches a\n          // slot's filter (e.g. CLOSED row in ?status=open cache)\n          // stays visually until next manual refetch (filter change,\n          // page nav, manual reload). Acceptable — the user opted into\n          // the action; carrying their drawer through it is more\n          // important than instantly hiding the row.\n          const statusUpdate = (serverArgs as { status?: 'OPEN' | 'CLOSED' }).status ?? null;\n          if (statusUpdate) {\n            // The `useTicketsList` query (in `use-tickets-list.ts`)\n            // returns `FindTicketResponse` — an OBJECT shape\n            // `{ tickets: TicketData[], count, page, totalPages, ... }` —\n            // NOT a bare `TicketData[]`. The previous version of this\n            // callback assumed an array and crashed at runtime with\n            // `t.map is not a function` on every close/reopen\n            // (reported 2026-05-29 in prod). Project the nested\n            // tickets array, map, and reassemble the wrapper.\n            queryClient.setQueriesData<TicketsCacheSlot | undefined>({ queryKey: ['tickets'] }, prev => {\n              if (!prev || !Array.isArray(prev.tickets)) return prev;\n              let mutated = false;\n              const nextTickets = prev.tickets.map(t => {\n                if (t.id !== ticket.id || t.status === statusUpdate) return t;\n                mutated = true;\n                return { ...t, status: statusUpdate };\n              });\n              return mutated ? { ...prev, tickets: nextTickets } : prev;\n            });\n          }\n\n          // Engagements ALWAYS need to refetch — the addendum / new\n          // attachment / status-change-note must land in the timeline.\n          // Scoped to the engagements query only; doesn't touch the\n          // list cache.\n          await queryClient.invalidateQueries({ queryKey: ['ticket-engagements'] });\n          return true;\n        });\n      } catch (err) {\n        const mapped = surfaceError(err, action);\n        if (mapped.removeRowFromCache) {\n          removeTicketFromCache(ticket.id);\n        }\n        return false;\n      } finally {\n        setRowBusy(ticket.id, false);\n      }\n    },\n    // `busyRowsRef` is read via .current — needs no dep entry. `busyRows`\n    // state isn't read inside this callback (only by `isRowBusy` selector\n    // outside), so listing it would churn the closure on every flag flip\n    // and cascade-recreate addNote/closeTicket/etc.\n    [setRowBusy, enqueue, executeTicketAction, queryClient, toast, surfaceError, removeTicketFromCache],\n  );\n\n  const sendMessage = useCallback(\n    async (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {\n      const trimmed = text.trim();\n      const hasText = trimmed.length > 0;\n      const hasFiles = attachments.length > 0;\n      if (!hasText && !hasFiles) return false;\n      // Clear any stale mapped error from a prior non-sendMessage action\n      // (closeTicket / reopenTicket) so the post-call read only picks up\n      // an error THIS sendMessage produced. Without this clear, a prior\n      // close-failure's mapped error could leak into the banner via the\n      // post-call `lastUpdateErrorRef.current` read.\n      lastUpdateErrorRef.current = null;\n      const ok = await updateTicket(\n        ticket,\n        {\n          ...(hasText ? { content_addendum: trimmed } : {}),\n          ...(hasFiles ? { attachments } : {}),\n        },\n        TOAST_COPY.comment_success,\n        'send message',\n      );\n      // Banner-state coupling: SUCCESS clears any stale failure banner\n      // for this ticket; FAILURE populates the banner ONLY for the\n      // reply-specific code subset (HUBSPOT_5XX / 400 / 404 / UNKNOWN).\n      // Other codes (TICKET_NOT_FOUND, HUBSPOT_DISCONNECTED, RATE_LIMITED)\n      // are full-row / full-system signals already covered by the\n      // existing toast + supportSystemDown handling — surfacing them in\n      // the inline banner too would be redundant.\n      if (ok) {\n        clearReplyError(ticket.external_id);\n      } else {\n        // Line 466's `.current = null` narrows the property to literal\n        // `null`. `tsc -p tsconfig.declarations.json` (declarations\n        // build, distinct from the `tsc --noEmit` pre-step) doesn't\n        // widen that narrowing across the `await updateTicket(...)`,\n        // so the read here is typed `never`. The runtime type IS\n        // `MappedTicketActionError | null` per the useRef declaration;\n        // the assertion just tells TS to honor it instead of the stale\n        // narrowing.\n        const mapped = lastUpdateErrorRef.current as MappedTicketActionError | null;\n        if (mapped && REPLY_BANNER_CODES.has(mapped.code)) {\n          setReplyError(ticket.external_id, mapped);\n        }\n        lastUpdateErrorRef.current = null;\n      }\n      return ok;\n    },\n    [updateTicket, clearReplyError, setReplyError],\n  );\n\n  const closeTicket = useCallback(\n    (ticket: TicketRef, resolution?: string) =>\n      updateTicket(\n        ticket,\n        {\n          status: 'CLOSED',\n          ...(resolution?.trim() ? { resolution: resolution.trim() } : {}),\n        },\n        TOAST_COPY.close_success,\n        'close ticket',\n      ),\n    [updateTicket],\n  );\n\n  const reopenTicket = useCallback(\n    (ticket: TicketRef) => updateTicket(ticket, { status: 'OPEN' }, TOAST_COPY.reopen_success, 'reopen ticket'),\n    [updateTicket],\n  );\n\n  return useMemo<UseTicketActionsReturn>(\n    () => ({\n      submitTicket,\n      sendMessage,\n      closeTicket,\n      reopenTicket,\n      isSubmittingForm,\n      isRowBusy,\n      replyErrorFor,\n      clearReplyError,\n    }),\n    [submitTicket, sendMessage, closeTicket, reopenTicket, isSubmittingForm, isRowBusy, replyErrorFor, clearReplyError],\n  );\n}\n\n/**\n * Translate a server error envelope into user-facing copy. Exported so\n * a future chat refactor can adopt the same translation table.\n */\nexport function mapTicketActionError(err: unknown): MappedTicketActionError {\n  if (err instanceof TicketActionFailure) {\n    switch (err.code) {\n      case 'PROPOSAL_NOT_CLAIMABLE':\n        return {\n          code: err.code,\n          message: 'This action was already processed.',\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n      case 'TICKET_NOT_FOUND':\n        return {\n          code: err.code,\n          message: 'This ticket is no longer available.',\n          supportSystemDown: false,\n          removeRowFromCache: true,\n        };\n      case 'TICKET_OWNERSHIP_DENIED':\n        return {\n          code: err.code,\n          message: 'You can only act on tickets you opened.',\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n      case 'HUBSPOT_DISCONNECTED':\n        return {\n          code: err.code,\n          message: 'Support system temporarily unavailable.',\n          supportSystemDown: true,\n          removeRowFromCache: false,\n        };\n      case 'RATE_LIMITED': {\n        const retryAfterRaw = err.response?.headers.get('Retry-After');\n        const retryAfterSeconds = retryAfterRaw ? parseInt(retryAfterRaw, 10) : undefined;\n        return {\n          code: err.code,\n          message: retryAfterSeconds\n            ? `Too many actions. Try again in ${retryAfterSeconds}s.`\n            : 'Too many actions. Try again shortly.',\n          supportSystemDown: false,\n          removeRowFromCache: false,\n          ...(retryAfterSeconds ? { retryAfterSeconds } : {}),\n        };\n      }\n      case 'INVALID_TOOL_ARGS':\n        return {\n          code: err.code,\n          message: 'Your input was rejected. Please review and try again.',\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n      case 'HUBSPOT_5XX':\n        return {\n          code: err.code,\n          message: \"We couldn't reach the support system. Your reply wasn't sent — please retry in a moment.\",\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n      case 'HUBSPOT_400_VALIDATION':\n        return {\n          code: err.code,\n          message: 'Your reply was rejected. Please rephrase or remove unsupported content and try again.',\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n      case 'HUBSPOT_404_THREAD':\n        return {\n          code: err.code,\n          message: 'This conversation is no longer accepting replies. Open a new ticket to continue.',\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n      case 'HUBSPOT_REPLY_UNKNOWN':\n        return {\n          code: err.code,\n          message: \"Your reply didn't go through. Please retry.\",\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n      default:\n        return {\n          code: 'UNKNOWN',\n          message: err.message || 'Something went wrong. Please try again.',\n          supportSystemDown: false,\n          removeRowFromCache: false,\n        };\n    }\n  }\n  return {\n    code: 'UNKNOWN',\n    message: err instanceof Error ? err.message : 'Something went wrong. Please try again.',\n    supportSystemDown: false,\n    removeRowFromCache: false,\n  };\n}\n\n/** Small id generator that doesn't require pulling in nanoid as a new\n *  dep. Sufficient for client-only optimistic ids. */\nfunction cryptoRandomId(): string {\n  if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {\n    return crypto.randomUUID();\n  }\n  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/** True iff any ['tickets', …] cache slot contains a ticket whose\n *  HubSpot id (external_id) matches the target. Used by the mirror-sync\n *  watcher to detect \"the real row just arrived\" and drop the placeholder\n *  early instead of waiting for the 30s timeout. */\nfunction cacheContainsTicket(queryClient: ReturnType<typeof useQueryClient>, expectedTicketId: string): boolean {\n  // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT a\n  // bare `TicketData[]`. The previous code's `Array.isArray(data)` guard\n  // silently fell through to `return false` on real responses — the\n  // post-create watcher therefore NEVER detected the real row arriving\n  // early and always waited the full timeout. Project the nested array.\n  const entries = queryClient.getQueriesData<TicketsCacheSlot | undefined>({\n    queryKey: ['tickets'],\n  });\n  for (const [, data] of entries) {\n    if (data && Array.isArray(data.tickets) && data.tickets.some(t => t.external_id === expectedTicketId)) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/** Resolve the canonical error code from the server's body + HTTP status.\n *  Body code wins when present; status-derived code is the fallback so a\n *  bare 429/412 (no body code) still maps cleanly through the user-facing\n *  branches. */\nfunction resolveErrorCode(bodyCode: string | undefined, status: number): TicketActionErrorCode {\n  if (bodyCode) return bodyCode as TicketActionErrorCode;\n  if (status === 429) return 'RATE_LIMITED';\n  if (status === 412) return 'HUBSPOT_DISCONNECTED';\n  return 'UNKNOWN';\n}\n\n// Re-export so callers can narrow the type when needed.\nexport type { AnyTicket, OptimisticTicket };\n","'use client';\n\n/**\n * Customer-scoped ticket list — wraps `POST /api/chat/agent/find-ticket`.\n * The server treats `query: ''` as \"list all my tickets\" for self-scoped\n * sources, accepts an optional `status` ('open' | 'closed') filter, and\n * paginates via `page` + `pageSize` (server caps pageSize at 100). All\n * three fold into ONE mirror SELECT with `count: 'exact'` so the\n * response carries both the rows AND total count in a single round-trip.\n *\n * Auth: rides on `embedAuthedFetch`. The server self-scopes by session\n * email — there's no client-supplied scope. An anon caller receives 401;\n * we short-circuit before fetching to avoid the wasted round-trip.\n *\n * Cache: keyed by `['tickets', 'self', identity, search, status, page, pageSize]`.\n * Each filter+page combo gets its own slot, so toggling the URL never\n * blows away the existing slot — TanStack just serves the slot for the\n * new key. `useTicketActions` calls `queryClient.invalidateQueries({queryKey:['tickets']})`\n * after every mutation so all slots refresh together.\n */\n\nimport { useQuery } from '@tanstack/react-query';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport { PAGE_PARAM_LIMITS, pageCount, positiveInt } from '../../../utils/search-params';\nimport type { TicketData } from '../types';\n\nconst FIND_TICKET_ENDPOINT = '/api/chat/agent/find-ticket';\nconst DEFAULT_PAGE_SIZE = 20;\n\ninterface FindTicketResponse {\n  tickets?: TicketData[];\n  count?: number;\n  totalCount?: number;\n  page?: number;\n  pageSize?: number;\n  totalPages?: number;\n  scope?: 'self' | 'all';\n}\n\nexport interface UseTicketsListFilters {\n  /** Customer email — the identity the parent already resolved via\n   *  `useChatIdentity` at the gate. THIS hook does NOT call\n   *  `useChatIdentity` itself: `useChatIdentity` is plain\n   *  `useState`+`useEffect` (no shared cache), so calling it again\n   *  here would mount with `user = null` racing the parent's already-\n   *  resolved identity. On the first render of `HelpCenterListAuthed`\n   *  that race produced `enabled = false` for one frame, the\n   *  conditional fell through to `!hasResults` → EmptyState flashed\n   *  before the tickets fetch fired. Drilling `customerEmail` in\n   *  from the parent makes the loading state monotonic. */\n  customerEmail: string;\n  /** Free-text query — server runs FTS on `search_vector`. Empty → no\n   *  search filter (self-scoped \"list all my tickets\"). */\n  search?: string;\n  /** Canonical 'open' | 'closed'. Server maps to the underlying mirror\n   *  status column. Empty / 'all' → no status filter. */\n  status?: string;\n  /** 1-based page number. Defaults to 1. */\n  page?: number;\n  /** Items per page (server caps at 100). Defaults to 20. */\n  pageSize?: number;\n}\n\nexport interface UseTicketsListReturn {\n  tickets: TicketData[];\n  isLoading: boolean;\n  isFetching: boolean;\n  error: Error | null;\n  refetch: () => void;\n  /** Wall-clock timestamp of the last successful fetch; null while\n   *  loading the first time. */\n  lastUpdatedAt: number | null;\n  /** Total ticket count across all pages (NOT just the current page).\n   *  Drives the `<UnifiedPagination>` total-pages calculation. */\n  totalCount: number;\n  /** 1-based current page (echoed from the server response so the URL\n   *  and the rendered set always agree). */\n  page: number;\n  /** Page size in use — echoed from the server (capped at 100). */\n  pageSize: number;\n  /** Pre-computed `Math.ceil(totalCount / pageSize)` clamped to ≥1 so\n   *  the pagination renders \"1 / 1\" instead of \"1 / 0\" on empty\n   *  result sets. */\n  totalPages: number;\n}\n\nexport function useTicketsList(filters: UseTicketsListFilters): UseTicketsListReturn {\n  // Endpoint from the runtime config (like every other endpoint) so embedders\n  // behind a reverse proxy route it through `/content/*`; falls back to the bare\n  // hub path when unconfigured.\n  const findTicketEndpoint = useRequiredChatRuntime().endpoints.findTicketUrl ?? FIND_TICKET_ENDPOINT;\n  const customerEmail = filters.customerEmail;\n  const search = (filters.search ?? '').trim();\n  const status = (filters.status ?? '').trim().toLowerCase();\n  const statusFilter = status && status !== 'all' ? status : '';\n  const page = positiveInt(filters.page, 1);\n  const pageSize = positiveInt(filters.pageSize, DEFAULT_PAGE_SIZE, { max: PAGE_PARAM_LIMITS.maxPageSize });\n\n  // `customerEmail` is the source of truth — parent (HelpCenterList)\n  // already gates on `identity.user?.email` being truthy before\n  // mounting the consumer of this hook. An empty string here means\n  // the consumer was mounted incorrectly (developer error); the\n  // query stays disabled.\n  const enabled = !!customerEmail;\n\n  // Identity-keyed cache: admin swaps proxy creds in /debug mid-session\n  // → new key from the parent → new cache slot → no flash of the\n  // previous identity's data.\n  const identityKey = customerEmail || 'anon';\n\n  const query = useQuery({\n    queryKey: ['tickets', 'self', identityKey, search, statusFilter, page, pageSize],\n    enabled,\n    // Caches OFF — every mount + every focus + every navigation triggers\n    // a fresh fetch. The ticket data is the customer's own list of\n    // tickets they expect to be live (sync agents reply, statuses flip,\n    // new comments arrive); a stale window of any size is worse than\n    // a sub-second refetch.\n    staleTime: 0,\n    gcTime: 0,\n    refetchOnMount: 'always',\n    refetchOnWindowFocus: true,\n    // NO interval polling (deleted 2026-08): live freshness is push-driven\n    // — `TicketLiveProvider` invalidates `['tickets']` on stream events.\n    queryFn: async (): Promise<FindTicketResponse> => {\n      const body: Record<string, string | number> = {\n        query: search,\n        page,\n        pageSize,\n      };\n      if (statusFilter) body.status = statusFilter;\n      const response = await embedAuthedFetch(findTicketEndpoint, {\n        method: 'POST',\n        body: JSON.stringify(body),\n      });\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(`find-ticket failed: ${response.status} ${text.slice(0, 200)}`);\n      }\n      return (await response.json()) as FindTicketResponse;\n    },\n  });\n\n  const data = query.data;\n  const totalCount = data?.totalCount ?? data?.count ?? data?.tickets?.length ?? 0;\n  const echoedPage = data?.page ?? page;\n  const echoedPageSize = data?.pageSize ?? pageSize;\n  const totalPages = data?.totalPages ?? pageCount(totalCount, echoedPageSize);\n\n  return {\n    tickets: data?.tickets ?? [],\n    // Loading-state-truth = `data === undefined`. TanStack v5's\n    // `isPending` / `isLoading` flags can be `false` in transient\n    // windows where the query is enabled-but-fetch-not-yet-fired\n    // OR where stale-data exists from a sibling cache slot — both\n    // produced the EmptyState flash on /tickets first load. Treating\n    // \"no data for THIS query slot yet\" as the universal loading\n    // signal can't lie:\n    //   - Initial render after enabled flips: data === undefined → load\n    //   - Background refetch with existing data: data !== undefined → no load\n    //   - Filter-change refetch landing on empty results: data?.tickets===[]\n    //     + isFetching → bridge skeleton (the `||` branch)\n    // Loading-state-truth = `data === undefined`. TanStack v5's\n    // `isPending` / `isLoading` flags can be `false` in transient\n    // windows where the query is enabled-but-fetch-not-yet-fired\n    // OR where stale-data exists from a sibling cache slot. Treating\n    // \"no data for THIS query slot yet\" as the universal loading\n    // signal can't lie:\n    //   - Initial render after enabled flips: data === undefined → load\n    //   - Background refetch with existing data: data !== undefined → no load\n    //   - Filter-change refetch landing on empty results: data?.tickets===[]\n    //     + isFetching → bridge skeleton (the `||` branch)\n    isLoading: enabled && (data === undefined || (query.isFetching && (data?.tickets ?? []).length === 0)),\n    isFetching: query.isFetching,\n    error: query.error ?? null,\n    refetch: () => {\n      void query.refetch();\n    },\n    lastUpdatedAt: query.dataUpdatedAt || null,\n    totalCount,\n    page: echoedPage,\n    pageSize: echoedPageSize,\n    totalPages,\n  };\n}\n","'use client';\n\n/**\n * The \"Open Ticket\" form at the top of <TicketCenter />.\n *\n * Composition only — every leaf is an existing oss-lib primitive:\n *   - `<Input>` for subject\n *   - `<Textarea>` for content\n *   - `<ChatAttachmentAddButton>` + `<ChatAttachmentChipStrip>` for files\n *   - `<Button>` for submit\n *\n * Submit gating combines:\n *   - subject + content trim non-empty\n *   - no uploads in flight\n *   - not currently submitting (single-flight, parent-owned)\n *   - support system online\n */\n\nimport { useState } from 'react';\nimport type { FormEvent } from 'react';\nimport { HUBSPOT_DO_NOT_COLLECT_FORM_PROPS } from '../../utils/hubspot-collected-forms';\nimport { ChatAttachmentAddButton, ChatAttachmentChipStrip } from '../chat/chat-attachment-bar';\nimport { useChatAttachments } from '../chat/hooks/use-chat-attachments';\nimport type { ChatAttachment } from '../chat/utils/chat-attachment-markdown';\nimport { Button } from '../ui/button';\nimport { Card } from '../ui/card';\nimport { Input } from '../ui/input';\nimport { Textarea } from '../ui/textarea';\nimport { TICKET_TEXT_MAX_CHARS } from './types';\n\nconst COUNTER_VISIBLE_AT = Math.floor(TICKET_TEXT_MAX_CHARS * 0.8);\n\nexport interface TicketOpenFormProps {\n  /** Wired to `useTicketActions().submitTicket`. Returns true on success\n   *  so the form can clear itself. */\n  onSubmit: (input: { subject: string; content: string; attachments: ChatAttachment[] }) => Promise<boolean>;\n  isSubmitting: boolean;\n  supportSystemDown: boolean;\n}\n\nexport function TicketOpenForm({ onSubmit, isSubmitting, supportSystemDown }: TicketOpenFormProps) {\n  const [subject, setSubject] = useState('');\n  const [content, setContent] = useState('');\n\n  // Reuse the chat composer's attachment hook directly — same upload\n  // pipeline, same readiness flags. The hook returns `readyAttachments`\n  // (the wire-shape projection) and `hasInflightUploads`.\n  const { attachments, readyAttachments, hasInflightUploads, addFiles, removeAttachment, clear } = useChatAttachments();\n\n  const trimmedSubject = subject.trim();\n  const trimmedContent = content.trim();\n  const overCap = content.length > TICKET_TEXT_MAX_CHARS;\n  const showCounter = content.length >= COUNTER_VISIBLE_AT;\n\n  const canSubmit =\n    !isSubmitting &&\n    !supportSystemDown &&\n    !hasInflightUploads &&\n    trimmedSubject.length > 0 &&\n    trimmedContent.length > 0 &&\n    !overCap;\n\n  const handleSubmit = async (e: FormEvent) => {\n    e.preventDefault();\n    if (!canSubmit) return;\n    const ok = await onSubmit({\n      subject: trimmedSubject,\n      content: trimmedContent,\n      attachments: readyAttachments,\n    });\n    if (ok) {\n      setSubject('');\n      setContent('');\n      clear();\n    }\n  };\n\n  return (\n    <Card className=\"p-6\">\n      <form onSubmit={handleSubmit} className=\"flex flex-col gap-6 md:flex-row\" {...HUBSPOT_DO_NOT_COLLECT_FORM_PROPS}>\n        <div className=\"min-w-0 flex-1 md:max-w-md\">\n          <h2 className=\"mb-2 text-ods-text-primary text-h2\">Need Support?</h2>\n          <p className=\"text-ods-text-secondary text-h6\">\n            Can&apos;t find what you&apos;re looking for? Submit a support ticket below — we&apos;ll follow up shortly.\n          </p>\n          {supportSystemDown && (\n            <p className=\"mt-4 text-ods-error text-h6\">\n              Support system temporarily unavailable. Please try again shortly.\n            </p>\n          )}\n        </div>\n\n        <div className=\"flex min-w-0 flex-1 flex-col gap-4\">\n          <div>\n            <label htmlFor=\"ticket-subject\" className=\"mb-1 block text-ods-text-primary text-h6\">\n              Ticket Subject\n            </label>\n            <Input\n              id=\"ticket-subject\"\n              type=\"text\"\n              placeholder=\"Enter Subject Here\"\n              value={subject}\n              onChange={e => setSubject(e.target.value)}\n              disabled={isSubmitting || supportSystemDown}\n              maxLength={200}\n            />\n          </div>\n\n          <div>\n            <label htmlFor=\"ticket-content\" className=\"mb-1 block text-ods-text-primary text-h6\">\n              Your Message\n            </label>\n            <Textarea\n              id=\"ticket-content\"\n              placeholder=\"Describe your issue or question in detail...\"\n              value={content}\n              onChange={e => setContent(e.target.value)}\n              disabled={isSubmitting || supportSystemDown}\n              rows={5}\n              className=\"resize-none\"\n            />\n            {showCounter && (\n              <p className={`mt-1 text-right text-h6 ${overCap ? 'text-ods-error' : 'text-ods-text-secondary'}`}>\n                {content.length}/{TICKET_TEXT_MAX_CHARS}\n              </p>\n            )}\n          </div>\n\n          <ChatAttachmentChipStrip\n            attachments={attachments}\n            onRemove={removeAttachment}\n            disabled={isSubmitting || supportSystemDown}\n          />\n\n          <div className=\"flex items-center justify-between gap-3\">\n            <ChatAttachmentAddButton\n              attachmentsEnabled={!supportSystemDown}\n              attachmentsCount={attachments.length}\n              onAddFiles={addFiles}\n              disabled={isSubmitting}\n            />\n            <Button type=\"submit\" disabled={!canSubmit} loading={isSubmitting}>\n              Open Ticket\n            </Button>\n          </div>\n        </div>\n      </form>\n    </Card>\n  );\n}\n","'use client';\n\n/**\n * Single ticket row + expanded details drawer.\n *\n * The COMPACT summary tile (`<ChatTicketItem>`) is the row chrome\n * specific to this composition — used by the lib's `<TicketCenter />`\n * embed. The drawer body itself is extracted into\n * `<TicketDetailDrawer />` so the hub's DevSection-style ticket card\n * (different chrome, same drawer) can drop it in too.\n *\n * Layout:\n *   1. `<ChatTicketItem>` summary tile. Clicking it toggles the\n *      `expandedTicketId` state owned by the parent `<TicketCenter>` —\n *      we use the item's existing `onClick` prop rather than nesting a\n *      `<CollapsibleTrigger>` (button-in-button is invalid).\n *   2. `<CollapsibleContent>` wrapping `<TicketDetailDrawer />`,\n *      rendered only when this row is the expanded one.\n */\n\nimport { useCallback, useRef } from 'react';\nimport { formatRelativeTime } from '../../utils/date-utils';\nimport { scrollElementIntoView } from '../../utils/scroll-into-view';\nimport { ChatTicketItem, type ChatTicketItemData } from '../chat/entity-cards/chat-ticket-item';\nimport { Collapsible, CollapsibleContent } from '../collapsible';\nimport { TicketDetailDrawer, type TicketDetailDrawerProps } from './ticket-detail-drawer';\nimport type { AnyTicket } from './types';\nimport { isOptimistic } from './types';\n\nexport interface TicketRowProps {\n  ticket: AnyTicket;\n  expanded: boolean;\n  onToggle: (ticketId: string) => void;\n  busy: boolean;\n  supportSystemDown: boolean;\n  onSendMessage: TicketDetailDrawerProps['onSendMessage'];\n  onClose: TicketDetailDrawerProps['onClose'];\n  onReopen: TicketDetailDrawerProps['onReopen'];\n  /** Called after a successful close/reopen so the parent can collapse\n   *  the drawer (status flipped — current action set is now stale). */\n  onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed'];\n  /** DOM `id` applied to the row's outer element. Parents that surface\n   *  a deep-link target set `ticket-<external_id>` so `useScrollToHash`\n   *  resolves it. `scroll-mt-24` is already baked into the outer\n   *  element regardless. */\n  id?: string;\n}\n\nexport function TicketRow({\n  ticket,\n  expanded,\n  onToggle,\n  busy,\n  supportSystemDown,\n  onSendMessage,\n  onClose,\n  onReopen,\n  onActionCollapsed,\n  id,\n}: TicketRowProps) {\n  // Optimistic placeholders have no drawer — the real id hasn't\n  // arrived yet, so action targets would be undefined.\n  const optimistic = isOptimistic(ticket);\n\n  // Scroll the clicked card to the top of the viewport. Every click\n  // scrolls — first-click expansion, same-row re-click, cross-row\n  // switch. The clicked card lands at the top.\n  //\n  // Cross-row gotcha: if ANOTHER row above this one is currently\n  // expanded, its drawer is about to collapse simultaneously with our\n  // toggle. We pre-subtract its height from the target Y so the\n  // smooth-scroll lands at the FINAL post-collapse position cleanly.\n  // Same pattern as `<HelpCenterCard>` — the only diff is the drawer\n  // id prefix (`ticket-drawer-` vs `help-center-drawer-`).\n  const rowRef = useRef<HTMLDivElement | null>(null);\n  const handleClick = useCallback(() => {\n    onToggle(ticket.id);\n    scrollElementIntoView(rowRef.current, {\n      adjustTargetY: raw => {\n        if (!rowRef.current) return raw;\n        const expandedDrawer = document.querySelector('div[id^=\"ticket-drawer-\"]');\n        if (!(expandedDrawer instanceof HTMLElement)) return raw;\n        const drawerRect = expandedDrawer.getBoundingClientRect();\n        const myRect = rowRef.current.getBoundingClientRect();\n        // Only adjust when the drawer is ABOVE us. Drawers below\n        // don't shift our position when they collapse.\n        if (drawerRect.bottom > myRect.top) return raw;\n        return raw - drawerRect.height;\n      },\n    });\n  }, [onToggle, ticket.id]);\n\n  const tileData: ChatTicketItemData = {\n    id: ticket.id,\n    title: ticket.subject ?? '(untitled)',\n    ticketNumber: `#${ticket.external_id}`,\n    status: ticket.status ?? 'OPEN',\n    // Surface the HubSpot pipeline stage label (\"New\" / \"Closed\" /\n    // \"Waiting on contact\" / \"Waiting on version release\") instead of\n    // the canonical \"Active\"/\"Resolved\" default. The variant + check\n    // icon still come from `status` (CLOSED → check; OPEN → no check),\n    // so the badge accurately reflects \"Closed\" with a checkmark.\n    statusLabel: ticket.pipeline_stage_label ?? undefined,\n    category: ticket.customer_company ?? undefined,\n    timeAgo: ticket.hubspot_updated_at ? formatRelativeTime(ticket.hubspot_updated_at) : undefined,\n    // Linked-work chip: surfaced whenever the ticket has a linked\n    // ClickUp task. Uses the linked task's own status so the chip text\n    // reads \"Working\" / \"Waiting on version release\" / etc. — useful\n    // signal pre-expand. Falls back to a generic \"Linked work\" label\n    // when the task exists but its status hasn't synced yet.\n    linkedTaskLabel: ticket.clickup\n      ? ticket.clickup.status\n        ? ticket.clickup.status.replace(/\\b\\w/g, c => c.toUpperCase())\n        : 'Linked work'\n      : undefined,\n  };\n\n  return (\n    <div ref={rowRef} id={id} className=\"scroll-mt-24\">\n      <Collapsible open={expanded && !optimistic} className=\"border-b border-ods-border last:border-b-0\">\n        <ChatTicketItem\n          ticket={tileData}\n          onClick={optimistic ? undefined : handleClick}\n          aria-expanded={expanded && !optimistic}\n          aria-controls={`ticket-drawer-${ticket.id}`}\n        />\n        <CollapsibleContent\n          id={`ticket-drawer-${ticket.id}`}\n          className=\"data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden\"\n        >\n          <TicketDetailDrawer\n            ticket={ticket}\n            busy={busy}\n            supportSystemDown={supportSystemDown}\n            onSendMessage={onSendMessage}\n            onClose={onClose}\n            onReopen={onReopen}\n            onActionCollapsed={onActionCollapsed}\n          />\n        </CollapsibleContent>\n      </Collapsible>\n    </div>\n  );\n}\n","'use client';\n\nimport * as CollapsiblePrimitive from '@radix-ui/react-collapsible';\n\nconst Collapsible = CollapsiblePrimitive.Root;\n\nconst CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;\n\nconst CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent };\n","'use client';\n\n/**\n * `<TicketDetailDrawer />` — the expanded view of a single ticket.\n *\n * Extracted from the original `ticket-row.tsx` so both compositions\n * share it:\n *   - Lib's `TicketRow` (compact `<ChatTicketItem>` summary + drawer\n *     beneath; what third-party embedders use via `TicketCenter`).\n *   - Hub's `<TicketCard>` (the DevSection-style card chrome on the\n *     openframe `/tickets` page).\n *\n * The drawer owns everything BELOW the summary tile:\n *   1. Metadata strip (ticket #, priority, pipeline, company, updated)\n *   2. Conversation timeline (`<TicketTimelinePanel>`) — original body\n *      turns + Note engagements + attachments\n *   3. Status-dependent actions (composer + close OR reopen)\n *\n * State is local to this component (composer text, attachment bag,\n * close-confirm dialog). The parent owns the ticket data + mutation\n * callbacks; we don't reach into the QueryClient.\n */\n\nimport { useEffect, useRef } from 'react';\nimport { useStickToBottom } from 'use-stick-to-bottom';\nimport { formatRelativeTime } from './../../utils/date-utils';\nimport { ChatMessageRow, ChatMessageRowSkeleton } from './../chat/chat-message-row';\nimport { useChatIdentity } from './../chat/hooks/use-chat-identity';\nimport type { ChatAttachment } from './../chat/utils/chat-attachment-markdown';\nimport { EmptyState } from './../empty-state';\nimport { Button } from './../ui/button';\nimport { SquareAvatar } from './../ui/square-avatar';\nimport { TicketAttachmentsList, type TicketAttachment } from './../ui/ticket-attachments-list';\nimport { useTicketEngagements } from './hooks/use-ticket-engagements';\nimport type { TicketEngagementFile } from './hooks/use-ticket-engagements';\nimport { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card';\nimport { useOptionalTicketLive } from './ticket-live-provider';\nimport { TicketReplyComposer } from './ticket-reply-composer';\nimport type { AnyTicket, TicketAssignedOwner, MappedTicketActionError } from './types';\nimport { isOptimistic, TICKET_MARK_READ_DEBOUNCE_MS } from './types';\n\n/** Identity bundle threaded through the action callbacks: local mirror\n *  UUID + HubSpot external_id. Actions send `external_id` to HubSpot\n *  (the only id it recognizes) and use `id` for the React-side mutex +\n *  TanStack cache. */\nexport type TicketRef = { id: string; external_id: string };\n\nexport interface TicketDetailDrawerProps {\n  ticket: AnyTicket;\n  busy: boolean;\n  supportSystemDown: boolean;\n  /** Single combined \"reply\" — text + optional attachments delivered as\n   *  ONE Note engagement. */\n  onSendMessage: (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => Promise<boolean>;\n  onClose: (ticket: TicketRef, resolution?: string) => Promise<boolean>;\n  onReopen: (ticket: TicketRef) => Promise<boolean>;\n  /** Called after a successful close/reopen so the parent can collapse\n   *  the drawer (status flipped — current action set is now stale). */\n  onActionCollapsed: () => void;\n  /** Persisted reply-failure surface — when non-null the drawer renders\n   *  an inline banner above the composer with the mapped copy + a\n   *  dismiss control. Distinct from the transient toast; the banner\n   *  stays visible so the customer can locate the failed draft after\n   *  the toast disappears. Cleared on the next successful send. */\n  replyError?: MappedTicketActionError | null;\n  /** Dismiss-X handler for the banner. Parent calls\n   *  `actions.clearReplyError(ticket.external_id)`. */\n  onClearReplyError?: () => void;\n}\n\nexport function TicketDetailDrawer({\n  ticket,\n  busy,\n  supportSystemDown,\n  onSendMessage,\n  onClose,\n  onReopen,\n  replyError,\n  onClearReplyError,\n}: TicketDetailDrawerProps) {\n  const isClosed = (ticket.status ?? '').toUpperCase() === 'CLOSED';\n  return (\n    <div className=\"flex flex-col gap-4 border-t border-ods-border bg-ods-card px-4 py-4\">\n      {/* Assignee header — surfaces who's looking at this ticket on the\n          support side. Populated server-side via `attachOwnerProfiles`;\n          falls back to \"Unassigned\" when no agent is assigned OR when\n          the owner couldn't be resolved (deleted between ticket update\n          + next owners reconcile). */}\n      <AssignedAgentRow assignedOwner={ticket.assignedOwner} />\n\n      {/* Linked ClickUp delivery — rendered only when the server's\n          `attachClickupTasks` step populated `ticket.clickup`. Customer\n          tickets with no linked task skip this entirely. The card itself\n          links out to ClickUp with the per-status color badge so the\n          customer can follow the delivery progress. */}\n      {ticket.clickup && <TicketLinkedDeliveryCard clickup={ticket.clickup} />}\n\n      <div>\n        <p className=\"mb-2 text-ods-text-secondary text-h5\">Conversation</p>\n        <TicketTimelinePanel ticket={ticket} />\n      </div>\n\n      <div className=\"border-t border-ods-border pt-4\">\n        {/* Reply-failure banner — populated by `useTicketActions` when\n            the last sendMessage attempt for THIS ticket failed with a\n            reply-specific code. Rendered above the composer/reopen so\n            the customer sees it in context of their failed draft. Open\n            (composer) actions still allow Retry; the closed (reopen)\n            state still shows the banner because the user might have\n            tried to reply to a then-closing ticket. */}\n        {replyError && <ReplyFailureBanner error={replyError} onDismiss={onClearReplyError ?? (() => undefined)} />}\n        {isClosed ? (\n          <ReopenAction\n            ticketRef={{ id: ticket.id, external_id: ticket.external_id }}\n            busy={busy}\n            supportSystemDown={supportSystemDown}\n            onReopen={onReopen}\n          />\n        ) : (\n          <TicketReplyComposer\n            ticket={ticket}\n            busy={busy}\n            supportSystemDown={supportSystemDown}\n            onSendMessage={onSendMessage}\n            onClose={onClose}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Render the ticket conversation as a chronological list of\n * `<ConversationCardRow>` cards inside a single bordered container.\n *\n * Top: the original ticket description (`ticket.body`). Below: every\n * Note engagement attached to the ticket via `useTicketEngagements` —\n * each with its own attachments rendered through the shared\n * `<TicketAttachmentsList>` (no more 📎-emoji chips).\n *\n * Legacy tickets whose old comments STILL live inside `ticket.body`\n * (joined by ` --- `) split on that delimiter so the historical\n * conversation surfaces correctly during the transition.\n *\n * Scroll behavior — INTENTIONALLY NONE. The drawer grows with the\n * conversation; the page scrolls. The previous `max-h-96 overflow-y-auto`\n * created two competing scroll surfaces (inner + page) which felt\n * janky on long threads and hid the composer on short ones. 2026\n * helpdesk best practice (UXPin / Coveo research) is a single\n * threaded surface that flows with the page.\n */\n// Bounded quantifiers (`{1,16}`) protect against the polynomial-time\n// backtracking class CodeQL flags for unbounded `\\s+` on user input.\n// 16 chars of leading/trailing whitespace around `---` is far more\n// than any composed ticket body needs, so no real input is rejected.\nconst TURN_SEPARATOR_RE = /[\\s]{1,16}---[\\s]{1,16}/g;\n\n// Slack-channel feed framing — ported from the hub's live community feed\n// (`components/slack/chat-interface.tsx`: `:62` bounded card, `:83` padding +\n// `overflow-y-auto`, `:85` `gap-4 md:gap-6` message column). Single source\n// within the ticket feed; the delivery `DevCardRowSkeletonList` keeps its own\n// (separate, untouched) frame literal. `max-h` is responsive (vs Slack's fixed\n// height).\nconst TICKET_FEED_FRAME = 'bg-ods-card border border-ods-border rounded-[6px] overflow-y-auto w-full';\n// FIXED height for EVERY state (skeleton, content, empty) — the Slack feed uses\n// a fixed-height box too (`chat-interface.tsx:62`). Fixed (not `max-h`) is the\n// fix for the \"open shows 1 message, then the container grows as engagements\n// land\" jank: the feed is its final size from first paint, so loaded content\n// just fills/scrolls inside it — the box never resizes.\nconst TICKET_FEED_HEIGHT = 'h-[60vh] md:h-[420px]';\nconst TICKET_FEED_INNER = 'flex flex-col gap-4 md:gap-6 px-4 md:px-6 py-4 md:py-6';\n// Enough skeleton rows to fill the fixed height (avatar 40px + header + 2 body\n// lines + gap-6) so the loading state looks like a full conversation.\nconst TICKET_FEED_SKELETON_ROWS = 6;\n\nfunction TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {\n  const identity = useChatIdentity();\n  // Optimistic placeholders don't have a real external_id yet — skip\n  // the engagement fetch until the real ticket lands.\n  const externalId = isOptimistic(ticket) ? null : ticket.external_id;\n  // Live conversation refresh is PUSH-driven: `TicketLiveProvider`\n  // invalidates `['ticket-engagements', id]` on stream events, so new\n  // agent replies surface without any interval (polling was deleted\n  // 2026-08; `refetchOnWindowFocus` remains the no-stream fallback).\n  const { engagements, isLoading } = useTicketEngagements(externalId, !!externalId);\n\n  // Read-state lifecycle — this panel mounts exactly while the drawer is\n  // open, so it owns: register the open ticket (provider suppresses\n  // unread bumps + masks the row), markRead on open, debounced markRead\n  // as new messages stream in, flush on close. The provider's callbacks\n  // are stable (useCallback) so these effects don't re-fire on unrelated\n  // context-value changes.\n  const live = useOptionalTicketLive();\n  const setOpenTicketId = live?.setOpenTicketId;\n  const markRead = live?.markRead;\n  const markReadDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    if (!externalId || !setOpenTicketId || !markRead) return undefined;\n    setOpenTicketId(externalId);\n    void markRead(externalId);\n    return () => {\n      // Flush a pending debounced markRead on close so a reply that\n      // arrived moments before closing is still receipted.\n      if (markReadDebounceRef.current) {\n        clearTimeout(markReadDebounceRef.current);\n        markReadDebounceRef.current = null;\n        void markRead(externalId);\n      }\n      setOpenTicketId(null);\n    };\n  }, [externalId, setOpenTicketId, markRead]);\n\n  const engagementsCount = engagements.length;\n  useEffect(() => {\n    if (!externalId || !markRead || engagementsCount === 0) return;\n    if (markReadDebounceRef.current) clearTimeout(markReadDebounceRef.current);\n    markReadDebounceRef.current = setTimeout(() => {\n      markReadDebounceRef.current = null;\n      void markRead(externalId);\n    }, TICKET_MARK_READ_DEBOUNCE_MS);\n  }, [engagementsCount, externalId, markRead]);\n\n  // Slack-style auto-tail (same lib mechanism `ChatMessageList` uses): jump to\n  // the newest message on open (`initial:'instant'`), smooth-scroll on a new\n  // reply. Called unconditionally here, BEFORE the empty/loading early-returns\n  // (Rules of Hooks); the refs attach ONLY to the content branch's scroll frame\n  // + column — never the cold-start skeleton (refs there would snap to skeleton\n  // height, then again to real content). This inner scroll is a SEPARATE\n  // container from `HelpCenterCard`'s page-level expand-scroll, so it never\n  // fights the \"scroll to top of the ticket card\" behavior.\n  const { scrollRef, contentRef } = useStickToBottom({ initial: 'instant', resize: 'smooth' });\n\n  const bodyTurns = ticket.body\n    ? ticket.body\n        .split(TURN_SEPARATOR_RE)\n        .map(t => t.trim())\n        .filter(Boolean)\n    : [];\n\n  // Suppress `bodyTurns[0]` (\"Original message\") when the engagement\n  // timeline already has a customer-authored message whose body\n  // matches it. The channel-first create path in the hub writes the\n  // customer's message body BOTH into `hubspot_tickets.content` AND\n  // into the first `hubspot_ticket_conversation_messages` row — pre-\n  // 2026-05-29 the bot-intake-burst filter on the server dropped the\n  // first-customer message from engagements, so `bodyTurns[0]` was\n  // the only render. With channel-first, the engagement survives and\n  // both surfaces render the same text. Drop the redundant\n  // \"Original message\" turn when we detect that overlap.\n  //\n  // Only `bodyTurns[0]` is conditional. Subsequent turns (\"Update N\",\n  // \"[Resolution]\") come from `update_ticket.content_addendum` and\n  // are NEVER customer-written, so the engagement timeline can't\n  // match them. Leave their indices intact so `Update 1` still\n  // labels as such when `bodyTurns[0]` is suppressed.\n  const customerEngagementBodies = new Set<string>(\n    engagements\n      .filter(e => e.authorRole === 'customer')\n      .map(e => (e.body ?? '').trim())\n      .filter(Boolean),\n  );\n  const suppressBodyTurnZero = bodyTurns.length > 0 && customerEngagementBodies.has(bodyTurns[0]);\n\n  // Customer name resolution precedence:\n  //   1. LIVE chat identity (`identity.user.name`) — when the viewer\n  //      is the ticket's own customer. Always fresh.\n  //   2. Mirror's `customer_name` — the HubSpot contact's display\n  //      name, captured by the ticket sync. Falls back here when the\n  //      viewer is NOT the customer (admin browsing / multi-contact\n  //      second viewer) so the customer bubble still shows the real\n  //      person's name instead of \"Customer\" generic.\n  //   3. Session email — last resort.\n  //   4. \"You\" — anonymous viewer.\n  const sessionEmailLower = identity.user?.email?.trim().toLowerCase() ?? null;\n  const isViewerTheCustomer =\n    !!sessionEmailLower && ticket.customer_emails.some(e => e.trim().toLowerCase() === sessionEmailLower);\n  const viewerName = identity.user?.name?.trim() || null;\n  const ticketCustomerName = ticket.customer_name?.trim() || null;\n  const customerName =\n    (isViewerTheCustomer ? viewerName : null) || ticketCustomerName || viewerName || identity.user?.email || 'You';\n  const customerAvatar = isViewerTheCustomer ? (identity.user?.avatarUrl ?? undefined) : undefined;\n\n  // Loading takes precedence over partial content — this is the fix for the\n  // \"open shows 1 message, then the rest load and the box grows\" jank. The\n  // ticket BODY is available synchronously, but the engagement timeline is\n  // fetched on open (caches off → EVERY open refetches). Rendering the body\n  // alone and then appending engagements as they arrive is the pop-in/grow the\n  // user hit. Instead: show the FULL-HEIGHT skeleton until the fetch settles,\n  // THEN render the whole conversation at once. Fixed height + skeleton-first =\n  // zero reflow and no partial render. `isLoading` (not `isFetching`) is true\n  // only on a cold open with no data yet — so a background refetch after sending\n  // a reply does NOT flash the skeleton; the new row just appends.\n  if (isLoading) {\n    // NO scroll refs here — they attach only to the real-content branch (refs on\n    // the skeleton would snap-to-bottom on skeleton height, then again on content).\n    return (\n      <div className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>\n        <div className={TICKET_FEED_INNER}>\n          {Array.from({ length: TICKET_FEED_SKELETON_ROWS }, (_, i) => (\n            <ChatMessageRowSkeleton key={i} />\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  if (bodyTurns.length === 0 && engagements.length === 0) {\n    return (\n      <EmptyState\n        type=\"generic\"\n        title=\"No conversation yet\"\n        description=\"Reply below to start the thread with the support team.\"\n        showCTA={false}\n      />\n    );\n  }\n\n  return (\n    <div ref={scrollRef} className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>\n      <div ref={contentRef} className={TICKET_FEED_INNER}>\n        {/* Customer-authored description + any legacy `---`-joined\n          comments. Always rendered ABOVE the engagement timeline as\n          \"Original message\" because the server's intake-burst filter\n          (see `filterCustomerVisibleTimeline` in\n          `hubspot-conversations-utils.ts`) drops the customer's first\n          message from engagements when it was part of the HubSpot\n          Custom Channel bot intake — bodyTurns IS the canonical\n          original for those tickets. For tickets created without bot\n          intake (admin-created, email channel) bodyTurns shows the\n          manually-entered description and engagements show subsequent\n          replies — same flow, no duplication. */}\n        {bodyTurns.map((turn, i) => {\n          // Drop the redundant first turn when the engagement timeline\n          // below already renders the same customer-authored body. See\n          // `suppressBodyTurnZero` derivation above for the rationale.\n          if (i === 0 && suppressBodyTurnZero) return null;\n          const isResolution = turn.startsWith('[Resolution]');\n          const text = isResolution ? turn.replace(/^\\[Resolution\\]\\s*/, '') : turn;\n          // Body turns don't carry per-turn timestamps — `ticket.body` is a\n          // single content field that HubSpot appends to. They render as the\n          // customer's own messages (no role chip — the Slack-channel feed has\n          // no role labels), oldest-first above the engagement timeline.\n          return (\n            <ChatMessageRow\n              key={`body-${i}-${turn.slice(0, 24)}`}\n              displayName={customerName}\n              avatarUrl={customerAvatar}\n              body={text}\n            />\n          );\n        })}\n\n        {/* Engagement timeline — interleaves customer-authored Custom\n          Channel messages (authorRole='customer') and team-authored\n          Notes (authorRole='support').\n          ATTRIBUTION RULES (per repo convention):\n            - CUSTOMER messages whose sender email matches the\n              current chat-identity user → render BOTH name AND\n              avatar LIVE from `identity.user.*` (1:1 from the\n              X-Chat-First-Name + X-Chat-Last-Name + X-Chat-Avatar-Url\n              headers that drive the identity webservice). This is\n              the source of truth for the logged-in user; we never\n              query `profiles` for customer display info.\n            - CUSTOMER messages from a DIFFERENT email (rare — the\n              /tickets surface only shows the current user's own\n              threads) → fall back to whatever the mirror has\n              (eng.authorName / eng.authorEmail), no profile lookup.\n            - SUPPORT/Note messages → the server has already\n              resolved `hubspot_owner_id` → owner email → `profiles`\n              row in `list-engagements`. `eng.authorName` +\n              `eng.authorAvatarUrl` carry the matched employee's\n              display info. When the owner isn't a known Flamingo\n              employee, both fields are null and we fall back to\n              the generic \"Support team\" treatment. */}\n        {engagements.map(eng => {\n          const isCustomer = eng.authorRole === 'customer';\n          // Per-message own-reply check: the server populates `authorId`\n          // with the message sender's email (resolved server-side via\n          // the HubSpot Conversations actor batch-read for Custom\n          // Channel visitor messages). When that email matches the\n          // session viewer's email, the bubble is the viewer's OWN\n          // reply and renders with LIVE chat identity (name + avatar\n          // from chat-auth headers).\n          const isOwnReply =\n            isCustomer &&\n            !!eng.authorId &&\n            !!identity.user?.email &&\n            eng.authorId.toLowerCase() === identity.user.email.toLowerCase();\n\n          let author: string;\n          let avatarSrc: string | undefined;\n          if (isCustomer && isOwnReply) {\n            // Live identity — 1:1 from chat auth headers.\n            author = identity.user?.name?.trim() || customerName;\n            avatarSrc = identity.user?.avatarUrl ?? undefined;\n          } else if (isCustomer) {\n            // Customer bubble whose sender email isn't the current\n            // session viewer. Two sub-cases:\n            //   (a) Same customer as the ticket but viewed by an admin\n            //       (or no sender_email on the engagement at all — the\n            //       Conversations API leaves it null on Custom Channels).\n            //       Use `ticket.customer_name` from the mirror — that's\n            //       the canonical HubSpot contact name for THIS ticket.\n            //   (b) Multi-contact ticket (CC/BCC) — a different customer\n            //       email appears here. We fall back to the same\n            //       `ticket.customer_name` rather than leak the second\n            //       contact's address; close enough for the rare case.\n            author = ticketCustomerName || 'Customer';\n            avatarSrc = undefined;\n          } else if (eng.authorName && eng.authorAvatarUrl) {\n            // Resolved Flamingo employee — server matched the HubSpot\n            // owner's email against `profiles` AND has an avatar to\n            // prove it. Avatar presence IS the trust signal: only\n            // owner-resolved employees carry one; raw HubSpot\n            // `sender_name` (bots, integrations, system actors,\n            // unmatched humans) carries name without avatar and gets\n            // the generic \"Support team\" treatment so we never\n            // attribute a customer-facing bubble to a bot string\n            // (\"HubSpot Bot\", \"Slack Integration\", etc.).\n            author = eng.authorName;\n            avatarSrc = eng.authorAvatarUrl;\n          } else {\n            // Unmatched / unknown / bot / integration / system actor —\n            // generic fallback. Customer doesn't need to see internal\n            // tool branding (which has the customer \"talking to\" a bot\n            // string instead of a person).\n            author = 'Support team';\n            avatarSrc = undefined;\n          }\n\n          // Role label: every engagement is a customer-visible\n          // Conversations message (customer ↔ agent on the Custom\n          // Channel). There are no internal Notes on this surface\n          // anymore — the read path explicitly filters them. So\n          // \"Reply\" for BOTH sides. The previous \"Note\" label for\n          // support bubbles was a legacy artifact from when Notes\n          // were rendered and made customers think their support\n          // engineer was leaving internal comments on their ticket.\n          const engAttachments = mapEngagementAttachments(eng.attachments);\n          return (\n            <ChatMessageRow\n              key={eng.id}\n              displayName={author}\n              avatarUrl={avatarSrc}\n              timeLabel={eng.createdAt ? formatRelativeTime(eng.createdAt) : null}\n              body={stripAttachmentsPreamble(eng.body ?? '')}\n              footer={\n                engAttachments.length > 0 ? (\n                  <div className=\"mt-2\">\n                    <TicketAttachmentsList attachments={engAttachments} size=\"compact\" />\n                  </div>\n                ) : null\n              }\n            />\n          );\n        })}\n\n        {/* No trailing refetch skeleton in the tailing feed: a skeleton mounted\n          inside `contentRef` on a background refetch would make the auto-tail\n          smooth-scroll to the skeleton and then again to the real row (a\n          double-jump). The smooth-tail to the appended real reply IS the\n          feedback. (Removed the former background-refetch rows={1} skeleton.) */}\n      </div>\n    </div>\n  );\n}\n\n/** Map the engagement file shape to the lib's canonical\n *  `TicketAttachment` so we can hand it straight to\n *  `<TicketAttachmentsList>`. Engagement `url` becomes a\n *  window.open-style download click; missing names degrade to\n *  `file-<id>` so the chip never renders an empty label. */\nfunction mapEngagementAttachments(files: TicketEngagementFile[]): TicketAttachment[] {\n  return files.map(f => {\n    // Captured before the `onDownload` closure below: a narrowing of `f.url`\n    // does not reach into a callback that runs on a later click.\n    const url = f.url;\n    return {\n      id: f.id,\n      fileName: f.name ?? `file-${f.id}`,\n      fileSize: f.size ? formatBytes(f.size) : '',\n      // Show an inline thumbnail for image attachments (the signed `url` is a\n      // viewable URL). Non-images fall back to the file-type icon. SquareAvatar\n      // degrades to initials on a broken/expired image URL.\n      thumbnailSrc: url && (f.mime?.startsWith('image/') ?? false) ? url : undefined,\n      onDownload: url ? () => window.open(url, '_blank', 'noopener,noreferrer') : undefined,\n    };\n  });\n}\n\nfunction formatBytes(size: number): string {\n  if (size < 1024) return `${size} B`;\n  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;\n  return `${(size / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/** Strip the redundant `Attachments:\\n\\n filename.png\\n filename2.png`\n *  preamble that the server appends to Note engagement bodies. We\n *  already render the same files through `<TicketAttachmentsList>` with\n *  proper icons + download buttons — showing the raw filename list\n *  again above the chip strip is duplicate noise. The regex matches\n *  ANY trailing block that starts with \"Attachments:\" (case-insensitive,\n *  optional leading whitespace) and consumes everything to end-of-string,\n *  so server-side wording tweaks like \"Attachments (3):\" still strip\n *  cleanly. Idempotent — a body with no preamble passes through\n *  untouched. */\nconst ATTACHMENTS_PREAMBLE_RE = /\\s*\\n\\s*Attachments\\b[^]*$/i;\nfunction stripAttachmentsPreamble(body: string): string {\n  return body.replace(ATTACHMENTS_PREAMBLE_RE, '').trim();\n}\n\nfunction ReopenAction({\n  ticketRef,\n  busy,\n  supportSystemDown,\n  onReopen,\n}: {\n  ticketRef: TicketRef;\n  busy: boolean;\n  supportSystemDown: boolean;\n  onReopen: TicketDetailDrawerProps['onReopen'];\n}) {\n  const handleReopen = async () => {\n    // Deliberately does NOT take (or call) the parent's `onActionCollapsed`. Pre-PR #1053\n    // every reopen was followed by a full list refetch which removed\n    // the (now-OPEN) row from a `?status=closed` view, so collapsing\n    // the drawer hid the disappearance flash. After #1053+#1055 the\n    // row stays in the list with the optimistic in-place status\n    // update — collapsing the drawer now actively dismisses the\n    // ticket the user is working on. Keep it mounted; the badge flip\n    // is enough feedback. (Reported 2026-05-29.)\n    void (await onReopen(ticketRef));\n  };\n  return (\n    <div className=\"flex justify-end\">\n      {/* Reopen is a secondary, reversible action — `outline` (not the filled\n          accent primary) so it reads as available without dominating the\n          closed-ticket view. */}\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"small\"\n        onClick={() => void handleReopen()}\n        disabled={busy || supportSystemDown}\n        loading={busy}\n      >\n        Reopen\n      </Button>\n    </div>\n  );\n}\n\n/**\n * Persistent banner above the drawer composer/actions when the most\n * recent customer reply failed with a reply-specific code (HUBSPOT_5XX\n * / 400 / 404 / UNKNOWN). The transient toast already fired at the\n * moment of failure; this banner stays until the next successful send\n * OR the user dismisses it explicitly. Wording is sourced from\n * `mapTicketActionError` so a future copy update lives in one place.\n *\n * 404_THREAD is the only terminal code in the set — the banner copy\n * reads \"open a new ticket\" and Retry would just re-fail. We still\n * render a Dismiss control instead of hiding Retry so the visual shape\n * is uniform; the parent's composer continues to function for any\n * non-thread-deletion reply path.\n */\nfunction ReplyFailureBanner({ error, onDismiss }: { error: MappedTicketActionError; onDismiss: () => void }) {\n  return (\n    <div\n      role=\"status\"\n      aria-live=\"polite\"\n      className=\"mb-3 flex items-start gap-3 rounded-md border border-ods-error bg-ods-error-secondary px-3 py-2 text-ods-error text-h6\"\n    >\n      <span className=\"font-medium leading-snug\">{error.message}</span>\n      <Button\n        type=\"button\"\n        variant=\"transparent\"\n        onClick={onDismiss}\n        aria-label=\"Dismiss reply failure\"\n        className=\"ml-auto border-transparent px-2 py-0.5 uppercase text-ods-error text-h6 hover:bg-ods-error-secondary\"\n      >\n        Dismiss\n      </Button>\n    </div>\n  );\n}\n\n/**\n * Compact \"Assigned to\" row at the top of the drawer. Surfaces the\n * support-side agent — name + avatar — so the customer knows who's\n * looking at their ticket. Renders \"Unassigned\" when the ticket has no\n * `hubspot_owner_id` OR when the owner couldn't be resolved against\n * the mirror (deleted between ticket update + next reconcile).\n *\n * Avatar comes from the canonical `<SquareAvatar variant=\"round\">` so\n * it picks up the initials-fallback + image-proxy behavior used\n * everywhere else in the lib (matches the dev-section message-bubble\n * avatars). No bespoke avatar markup.\n */\nfunction AssignedAgentRow({ assignedOwner }: { assignedOwner: TicketAssignedOwner | null }) {\n  // Display label precedence:\n  //   1. `name` from the mirror (employee match OR HubSpot's first+last)\n  //   2. `email` local-part — covers HubSpot owners that exist but have\n  //      no name (rare but real; the ticket IS assigned and rendering\n  //      \"Unassigned\" would be misleading)\n  //   3. \"Unassigned\" — only when the ticket has no `assigned_to` OR\n  //      the owner couldn't be resolved against the mirror at all\n  const trimmedName = assignedOwner?.name?.trim() || null;\n  const emailFallback = assignedOwner?.email?.trim() || null;\n  const displayLabel = trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null);\n  return (\n    <div className=\"flex items-center gap-2 text-h6\">\n      <span className=\"text-ods-text-secondary text-h5\">Assigned to</span>\n      {displayLabel ? (\n        <span className=\"flex items-center gap-1.5 font-medium text-ods-text-primary\">\n          {/* Avatar loads direct; `SquareAvatar`'s own onError falls back to initials. */}\n          <SquareAvatar\n            size=\"sm\"\n            variant=\"round\"\n            src={assignedOwner?.avatarUrl ?? undefined}\n            alt={displayLabel}\n            fallback={displayLabel}\n          />\n          {displayLabel}\n        </span>\n      ) : (\n        <span className=\"italic text-ods-text-secondary\">Unassigned</span>\n      )}\n    </div>\n  );\n}\n","'use client';\n\n/**\n * Fetch the conversation timeline (Note engagements + attachments) for\n * a single ticket. Powers the drawer's timeline view — separate from\n * `useTicketsList` because the engagements are expensive to fetch\n * (multi-stage HubSpot API calls) and only needed when a row is\n * expanded.\n *\n * Auth: rides `embedAuthedFetch` (same proxy creds as the chat). The\n * server-side route asserts ticket ownership via\n * `ticketBelongsToCustomer` for self-scoped sources before reading\n * notes — a customer can't enumerate another customer's notes by\n * guessing ticket ids.\n */\n\nimport { useQuery } from '@tanstack/react-query';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport { useChatIdentity } from '../../chat/hooks/use-chat-identity';\n\nconst LIST_ENGAGEMENTS_ENDPOINT = '/api/chat/agent/list-engagements';\n\nexport interface TicketEngagementFile {\n  id: string;\n  name: string | null;\n  url: string | null;\n  mime: string | null;\n  size: number | null;\n}\n\nexport interface TicketEngagement {\n  id: string;\n  body: string | null;\n  authorId: string | null;\n  /** Whether this engagement is customer-authored (Custom Channels\n   *  Messages — INCOMING direction) or team-authored (Notes + future\n   *  OUTGOING messages). Drives avatar variant + the \"Customer\"/\"Support\n   *  team\" header label in the drawer's conversation thread. */\n  authorRole: 'customer' | 'support';\n  /** Display name. Server resolves it differently per role:\n   *    - `support` (Notes) → HubSpot owner id is resolved to an owner\n   *      email, then matched against our `profiles` table; the matched\n   *      employee's `full_name` is returned here. Null when the owner\n   *      isn't a known Flamingo employee.\n   *    - `customer` (Conversations messages) → null on new messages\n   *      (drawer renders identity.user.name LIVE for the current\n   *      user's own messages). Set only on legacy rows from earlier\n   *      migrations. */\n  authorName: string | null;\n  /** Resolved author email — for `support` it's the HubSpot owner's\n   *  email; for `customer` it's the message sender. Used by the\n   *  drawer to cross-check \"is this me?\" against `identity.user.email`. */\n  authorEmail: string | null;\n  /** Avatar URL. For `support`, resolved from the matched `profiles`\n   *  row's `avatar_url`. Null when the owner isn't a known Flamingo\n   *  employee. For `customer`, always null on the wire (drawer reads\n   *  identity.user.avatarUrl live for own messages). */\n  authorAvatarUrl: string | null;\n  createdAt: string;\n  attachments: TicketEngagementFile[];\n}\n\ninterface ListEngagementsResponse {\n  engagements?: TicketEngagement[];\n  count?: number;\n}\n\nexport interface UseTicketEngagementsReturn {\n  engagements: TicketEngagement[];\n  isLoading: boolean;\n  isFetching: boolean;\n  error: Error | null;\n  refetch: () => void;\n}\n\nexport function useTicketEngagements(\n  externalTicketId: string | null | undefined,\n  enabled = true,\n): UseTicketEngagementsReturn {\n  const identity = useChatIdentity();\n  const identityKey = identity.user?.email ?? 'anon';\n  // Endpoint from the runtime config (like every other endpoint); falls back to\n  // the bare hub path when unconfigured.\n  const listEngagementsEndpoint = useRequiredChatRuntime().endpoints.listEngagementsUrl ?? LIST_ENGAGEMENTS_ENDPOINT;\n\n  // \"Will this ticket fetch its timeline once identity is ready?\" — i.e. it's a\n  // real, non-optimistic ticket the caller enabled. INDEPENDENT of whether\n  // identity has resolved yet, so the loading state is correct from the very\n  // first render (before `useChatIdentity` settles).\n  const fetchable = enabled && !!externalTicketId && !externalTicketId.startsWith('temp-'); // optimistic placeholders have no real id yet\n\n  const queryEnabled = fetchable && identity.authTier !== 'anon' && !!identity.user?.email;\n\n  const query = useQuery({\n    queryKey: ['ticket-engagements', externalTicketId, identityKey],\n    enabled: queryEnabled,\n    // Caches OFF — same reasoning as `useTicketsList`. The conversation\n    // timeline must reflect HubSpot truth on every drawer-open; a stale\n    // window risks hiding a freshly-arrived agent reply.\n    staleTime: 0,\n    gcTime: 0,\n    refetchOnMount: 'always',\n    refetchOnWindowFocus: true,\n    // NO interval polling (deleted 2026-08): new agent replies arrive via\n    // `TicketLiveProvider`'s stream-driven invalidation of\n    // `['ticket-engagements', id]`; focus/mount refetch is the no-stream\n    // fallback.\n    queryFn: async (): Promise<TicketEngagement[]> => {\n      const response = await embedAuthedFetch(listEngagementsEndpoint, {\n        method: 'POST',\n        body: JSON.stringify({ ticket_id: externalTicketId }),\n      });\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(`list-engagements failed: ${response.status} ${text.slice(0, 200)}`);\n      }\n      const body = (await response.json()) as ListEngagementsResponse;\n      return Array.isArray(body.engagements) ? body.engagements : [];\n    },\n  });\n\n  return {\n    engagements: query.data ?? [],\n    // Loading-state truth that prevents the \"body → blink → skeleton → data\"\n    // double-flash. The bug: `useChatIdentity` starts at anon defaults and\n    // resolves async, so on the first render `queryEnabled` is false and the\n    // OLD `queryEnabled && query.isLoading` returned FALSE — the panel rendered\n    // the ticket body, THEN identity resolved, the query enabled, isLoading\n    // flipped true → skeleton appeared (the blink), then data landed.\n    //\n    // Fix: for a fetchable ticket we are \"loading\" whenever we don't yet have\n    // the timeline to show — that includes the window while identity is still\n    // resolving (so we skeleton from the FIRST render, never the body) AND the\n    // cold query fetch (`data === undefined`). A background poll keeps\n    // `query.data` defined, so it never re-flashes the skeleton. Non-fetchable\n    // (optimistic/disabled) or a resolved-anon viewer → not loading.\n    isLoading: fetchable && (identity.isLoading || (queryEnabled && query.data === undefined)),\n    isFetching: query.isFetching,\n    error: query.error ?? null,\n    refetch: () => {\n      void query.refetch();\n    },\n  };\n}\n","'use client';\n\n/**\n * `<TicketLinkedDeliveryCard />` — renders the ClickUp delivery task\n * linked to a HubSpot ticket as a single `<DeliveryRow />` (the same\n * primitive `/bug-fixes-and-enhancements` uses for its row tiles).\n *\n * Navigation is unified with the chat-inline delivery card: both go\n * through `buildDevSectionUrl('delivery', external_id)`, composed\n * server-side and shipped on `clickup.delivery_href`. The URL carries\n * `?search=<external_id>` so the landing list filters to that exact\n * task (the canonical \"deep-link to a specific delivery row\" mechanism\n * already in place for chat).\n *\n * Soft-nav happens via the env-aware `next/link` shim that the host\n * registers — back-button restores /tickets with React state intact\n * (no skeleton flash, no TanStack-Query cache loss).\n */\n\nimport type { DeliveryItem } from '../../types/delivery';\nimport { DeliveryRow } from '../shared/delivery/delivery-row';\nimport type { TicketClickupSummary } from './types';\n\nexport interface TicketLinkedDeliveryCardProps {\n  clickup: TicketClickupSummary;\n  className?: string;\n}\n\nexport function TicketLinkedDeliveryCard({ clickup, className }: TicketLinkedDeliveryCardProps) {\n  const item: DeliveryItem = {\n    id: clickup.external_id,\n    title: clickup.title ?? 'Linked delivery task',\n    description: clickup.description ?? '',\n    status: clickup.status ?? 'unknown',\n    statusColor: clickup.status_color ?? '#87909e',\n    taskType: clickup.task_type ?? 'Request',\n    customItemId: clickup.custom_item_id,\n    listNames: clickup.list_names,\n    dateOpened: clickup.date_opened ?? 0,\n    dateUpdated: clickup.date_updated ?? clickup.date_opened ?? 0,\n    dateClosed: clickup.date_closed,\n    clickupUrl: clickup.clickup_url ?? '',\n  };\n\n  return (\n    <div className={`overflow-hidden rounded-md border border-ods-border bg-ods-bg ${className ?? ''}`}>\n      <DeliveryRow item={item} href={clickup.delivery_href} caption=\"Linked delivery\" />\n    </div>\n  );\n}\n","'use client';\n\nimport { useCallback, useState } from 'react';\nimport { ChatAttachmentAddButton, ChatAttachmentChipStrip } from './../chat/chat-attachment-bar';\nimport { ChatInput } from './../chat/chat-input';\nimport { useChatAttachments } from './../chat/hooks/use-chat-attachments';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from './../ui/alert-dialog';\nimport { Button } from './../ui/button';\nimport { Textarea } from './../ui/textarea';\nimport type { TicketDetailDrawerProps, TicketRef } from './ticket-detail-drawer';\nimport type { AnyTicket } from './types';\nimport { TICKET_TEXT_MAX_CHARS } from './types';\n\nexport interface TicketReplyComposerProps {\n  ticket: AnyTicket;\n  busy: boolean;\n  supportSystemDown: boolean;\n  onSendMessage: TicketDetailDrawerProps['onSendMessage'];\n  onClose: TicketDetailDrawerProps['onClose'];\n}\n\n/**\n * Open-ticket reply composer — REUSES the exact same layout as the global\n * Ask-AI chat composer (`embeddable-chat.tsx`): the shared `<ChatInput>` with\n * the staged-file chip strip above it and the attachment `+` button in a\n * controls row BELOW the input (identical placement to the global chat), plus\n * the destructive close-confirm `AlertDialog`.\n *\n * Replaces the former `OpenActions` raw-`<Textarea>` composer. The text lives\n * inside `ChatInput`; this component owns only the attachment bag + the close\n * dialog. Send semantics:\n *   - `sending={busy || hasInflightUploads}` disables the input while sending\n *     OR uploading (same as the global chat); `allowEmptySend` lets an\n *     attachments-only reply send once uploads finish;\n *   - the typed draft + staged files survive a FAILED send: `handleSend`\n *     returns `false`, so `ChatInput` keeps the text and we only `clear()` the\n *     attachments on success;\n *   - close does NOT collapse the drawer (no `onActionCollapsed`).\n *\n * `disabled={supportSystemDown}` is the only flag that drives the \"Connection\n * lost…\" placeholder. The `+` attach gate stays `!supportSystemDown` (same gate\n * the old composer used).\n */\nexport function TicketReplyComposer({\n  ticket,\n  busy,\n  supportSystemDown,\n  onSendMessage,\n  onClose,\n}: TicketReplyComposerProps) {\n  const [resolution, setResolution] = useState('');\n  const [closeDialogOpen, setCloseDialogOpen] = useState(false);\n  const attachments = useChatAttachments();\n  // Pulled out of the bag ONCE: `useChatAttachments` returns a fresh object\n  // every render, so depending on it would rebuild `handleSend` every render.\n  // Both of these are `useMemo`/`useCallback` results, so they are the stable\n  // half and no receiver is lost by destructuring.\n  const { readyAttachments, clear: clearAttachments } = attachments;\n\n  const ticketRef: TicketRef = { id: ticket.id, external_id: ticket.external_id };\n  const hasReadyFiles = readyAttachments.length > 0;\n\n  const handleSend = useCallback(\n    async (text: string): Promise<boolean> => {\n      const ref: TicketRef = { id: ticket.id, external_id: ticket.external_id };\n      const ok = await onSendMessage(ref, text.trim(), readyAttachments);\n      if (ok) clearAttachments();\n      return ok;\n    },\n    [onSendMessage, ticket.id, ticket.external_id, readyAttachments, clearAttachments],\n  );\n\n  const confirmClose = async () => {\n    setCloseDialogOpen(false);\n    await onClose(ticketRef, resolution.trim() || undefined);\n    setResolution('');\n    // Intentionally NO `onActionCollapsed()` — collapsing the drawer after a\n    // close dismisses the ticket the user is working on (PR #1053). The\n    // optimistic in-place status update keeps the row mounted with the new\n    // badge; that is the only feedback needed.\n  };\n\n  const disabled = busy || supportSystemDown;\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {/* Unified composer — mirrors the global Ask-AI chat layout\n          (embeddable-chat.tsx :1160-1206): compact staged-file chip strip\n          above, the shared <ChatInput> (Send icon = the PRIMARY action), then a\n          quiet bottom toolbar: attachment \"+\" on the left, and a LOW-EMPHASIS\n          \"Close ticket\" text button on the right. Closing is reversible (Reopen\n          exists), so it is NOT styled as destructive/danger — that would\n          over-signal a routine, undoable status change (UX best practice:\n          reserve red for irreversible actions). */}\n      <ChatAttachmentChipStrip\n        attachments={attachments.attachments}\n        onRemove={attachments.removeAttachment}\n        disabled={disabled}\n        size=\"compact\"\n      />\n      <ChatInput\n        fullWidth\n        // Focus the reply box when the drawer opens so the customer can type\n        // immediately. `ChatInput`'s autoFocus uses `{ preventScroll: true }`,\n        // so this does NOT scroll the page — the card's smooth scroll-to-top\n        // (HelpCenterCard) wins, and the input stays focused + visible (it\n        // sits within the viewport below the fixed-height feed).\n        autoFocus\n        placeholder=\"Type a reply…\"\n        sending={busy || attachments.hasInflightUploads}\n        disabled={supportSystemDown}\n        allowEmptySend={hasReadyFiles}\n        maxLength={TICKET_TEXT_MAX_CHARS}\n        onSend={handleSend}\n      />\n      <div className=\"flex w-full items-center gap-2\">\n        {!supportSystemDown && (\n          <ChatAttachmentAddButton\n            attachmentsEnabled\n            attachmentsCount={attachments.attachments.length}\n            onAddFiles={attachments.addFiles}\n            disabled={disabled}\n            size=\"compact\"\n          />\n        )}\n        <div className=\"min-w-0 flex-1\" />\n        <Button\n          type=\"button\"\n          variant=\"transparent\"\n          size=\"small\"\n          onClick={() => setCloseDialogOpen(true)}\n          disabled={disabled}\n          className=\"text-ods-text-secondary hover:text-ods-text-primary\"\n        >\n          Close ticket\n        </Button>\n      </div>\n\n      {/* Confirm dialog — collects an optional resolution note. Closing is\n          REVERSIBLE, so the confirm action is the standard accent primary, NOT\n          a red destructive button. */}\n      <AlertDialog open={closeDialogOpen} onOpenChange={setCloseDialogOpen}>\n        <AlertDialogContent className=\"border-ods-border bg-ods-card\">\n          <AlertDialogHeader>\n            <AlertDialogTitle className=\"text-ods-text-primary\">Close this ticket?</AlertDialogTitle>\n            <AlertDialogDescription className=\"text-ods-text-secondary\">\n              Add an optional resolution note below. You can reopen the ticket later if needed.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <Textarea\n            value={resolution}\n            onChange={e => setResolution(e.target.value)}\n            placeholder=\"Resolution (optional)\"\n            rows={3}\n            maxLength={TICKET_TEXT_MAX_CHARS}\n            className=\"mt-2\"\n          />\n          <AlertDialogFooter>\n            <AlertDialogCancel\n              disabled={busy}\n              className=\"border-ods-border bg-transparent text-ods-text-primary hover:bg-ods-border\"\n            >\n              Cancel\n            </AlertDialogCancel>\n            <AlertDialogAction\n              onClick={() => void confirmClose()}\n              disabled={busy}\n              className=\"bg-ods-accent text-ods-text-on-accent hover:bg-ods-accent-hover\"\n            >\n              Close ticket\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </div>\n  );\n}\n","'use client';\n\n/**\n * `<HelpCenterList />` — the full Help Center surface (the openframe\n * `/tickets` page mounts this directly; third-party embedders can mount\n * it inside their own `<PageShell>` to get the same UX).\n *\n * Mounts `<DevSectionPage sectionKey=\"tickets\">` so the page chrome\n * (hero + search + status filter + back button) is identical to\n * `/roadmap`, `/bug-fixes-and-enhancements`, `/releases`. The\n * \"Open a new ticket\" form lives in the new `preControls` slot above\n * the search/filter row.\n *\n * State ownership:\n *   - URL params (`?search=`, `?status=`, `?page=`) → `DevSectionView`\n *     writes search + status, `<UnifiedPagination>` writes page.\n *     `useTicketsList({ search, status, page })` reads them.\n *   - Optimistic placeholders → kept LOCAL (not in TanStack cache) so a\n *     refetch (URL filter change) doesn't blow them away mid-flight.\n *   - Expanded row → single id (only one drawer open at a time).\n *   - Mutations → `useTicketActions` with prepend/remove callbacks\n *     wired to the local placeholder state.\n *\n * Anon visitors get the same `DevSectionPage` chrome (hero + back\n * button) but with a single \"Sign in\" `<EmptyState>` body — no form,\n * no list, no fetch.\n */\n\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useState } from 'react';\nimport { useSearchParams, useRouter, usePathname } from '../../embed-shims';\nimport { useScrollToHash } from '../../hooks/use-scroll-to-hash';\nimport { toast as defaultToast } from '../../hooks/use-toast';\nimport { DEV_SECTION_PARAM_KEYS, devSectionAnchorId } from '../../utils/dev-sections/dev-section-param-keys';\nimport { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';\nimport { positiveInt } from '../../utils/search-params';\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity';\nimport { EmptyState } from '../empty-state';\nimport { DevSectionPage } from '../shared/dev-section';\nimport { DevCardRowSkeletonList } from '../shared/dev-section/dev-card-row';\nimport { Button } from '../ui';\nimport { UnifiedPagination } from '../unified-pagination';\nimport { HelpCenterCard } from './help-center-card';\nimport { HelpCenterCreateForm, HelpCenterCreateFormSkeleton } from './help-center-create-form';\nimport { useTicketActions } from './hooks/use-ticket-actions';\nimport { useTicketsList } from './hooks/use-tickets-list';\nimport type { AnyTicket, OptimisticTicket, TicketsCacheSlot } from './types';\nimport { isOptimistic, TICKET_OPEN_PARAM } from './types';\n\nexport interface HelpCenterListProps {\n  /** Toast override (test-friendly). Defaults to the lib's shared\n   *  toast singleton. */\n  toast?: typeof defaultToast;\n  /** Back-button forwarded to the internal `DevSectionPage` chrome (same shape\n   *  as `DevSectionPage` / `LegalDocumentPage`: `{ label?, href? }`, or `false`\n   *  to hide). Omit ⇒ `DevSectionPage`'s default (`Back to home` → `/`), which\n   *  embedders whose home isn't `/` MUST override. */\n  backButton?: { label?: string; href?: string } | false;\n  /** Override the hero title (forwarded to `DevSectionPage.title`). Defaults to\n   *  the `tickets` section copy (\"Help Center\"). Set this to brand the surface\n   *  for an embed that wants its own label (e.g. \"Support Tickets\"). */\n  title?: string;\n  /** Render the standalone `<PageShell>` (forwarded to the internal\n   *  `DevSectionPage`). Default true. Pass false when the host layout already\n   *  provides the page container (avoids a nested `<main>`). */\n  shell?: boolean;\n}\n\nexport function HelpCenterList({ toast = defaultToast, backButton, title, shell }: HelpCenterListProps = {}) {\n  const identity = useChatIdentity();\n  const searchParams = useSearchParams();\n  const router = useRouter();\n  const pathname = usePathname();\n\n  const search = searchParams.get(DEV_SECTION_PARAM_KEYS.search) || '';\n  const status = searchParams.get('status') || 'all';\n  // Deep-link: `?ticket=<external_id>` auto-opens that ticket's drawer on load.\n  // Same GET-param plumbing as `?search=` — read here, drilled to the authed\n  // child which expands the matching row once it's in the fetched list.\n  const ticketParam = searchParams.get(TICKET_OPEN_PARAM) || '';\n  // 1-based page from the URL. `<UnifiedPagination>` writes `?page=N`\n  // on navigation; we read it here and re-fetch on change. Invalid\n  // values fall back to page 1.\n  const page = positiveInt(searchParams.get('page'), 1);\n\n  // Identity gate FIRST — anon visitors skip every fetch + hook below.\n  // `useChatIdentity` has a brief `isLoading` window on first render\n  // before the identity resolves; we render the skeleton until it lands\n  // to avoid flashing the sign-in EmptyState for authed users. The\n  // skeleton mirrors the AUTHED layout — form placeholder above the\n  // search/filter row, list-rows skeleton below — so the chrome\n  // doesn't shift vertically when identity resolves and the real form\n  // mounts in the `preControls` slot.\n  if (identity.isLoading) {\n    return (\n      <DevSectionPage\n        sectionKey=\"tickets\"\n        backButton={backButton}\n        title={title}\n        shell={shell}\n        preControls={<HelpCenterCreateFormSkeleton />}\n      >\n        <DevCardRowSkeletonList />\n      </DevSectionPage>\n    );\n  }\n  if (identity.authTier === 'anon' || !identity.user?.email) {\n    return (\n      <DevSectionPage sectionKey=\"tickets\" backButton={backButton} title={title} shell={shell}>\n        <EmptyState\n          type=\"generic\"\n          title=\"Sign in to manage tickets\"\n          description=\"View, open, and follow up on support tickets after signing in.\"\n          showCTA={false}\n        />\n      </DevSectionPage>\n    );\n  }\n\n  // Identity is loaded + has an email (gated above). Resolve the\n  // authoritative session display name + email HERE so the create-form\n  // child doesn't have to call `useChatIdentity` itself — that hook is\n  // a plain `useState`+`useEffect` (no shared cache), so a second call\n  // in the child would race the first render and lock RHF's\n  // `defaultValues.email` to '' for the form's lifetime.\n  const sessionName =\n    [identity.user?.firstName, identity.user?.lastName].filter(Boolean).join(' ').trim() ||\n    identity.user?.email?.split('@')[0] ||\n    'Customer';\n  const sessionEmail = identity.user.email;\n\n  return (\n    <HelpCenterListAuthed\n      search={search}\n      status={status}\n      page={page}\n      ticketParam={ticketParam}\n      searchParams={searchParams}\n      router={router}\n      pathname={pathname}\n      toast={toast}\n      sessionName={sessionName}\n      sessionEmail={sessionEmail}\n      backButton={backButton}\n      title={title}\n      shell={shell}\n    />\n  );\n}\n\ninterface AuthedProps {\n  search: string;\n  status: string;\n  page: number;\n  /** `?ticket=<external_id>` deep-link target — auto-opens that drawer. */\n  ticketParam: string;\n  searchParams: ReturnType<typeof useSearchParams>;\n  router: ReturnType<typeof useRouter>;\n  pathname: string;\n  toast: typeof defaultToast;\n  sessionName: string;\n  sessionEmail: string;\n  backButton?: { label?: string; href?: string } | false;\n  title?: string;\n  shell?: boolean;\n}\n\nfunction HelpCenterListAuthed({\n  search,\n  status,\n  page,\n  ticketParam,\n  searchParams,\n  router,\n  pathname,\n  toast,\n  sessionName,\n  sessionEmail,\n  backButton,\n  title,\n  shell,\n}: AuthedProps) {\n  const queryClient = useQueryClient();\n  const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([]);\n  const [supportSystemDown, setSupportSystemDown] = useState(false);\n\n  // SINGLE source of truth for \"which ticket is open\" = the `?ticket=<external_id>`\n  // URL param (same model as `?search=` / `?status=`). Click-to-open and the\n  // deep-link path are now ONE code path: a click writes the param, the drawer's\n  // open state is DERIVED from the param. No separate `expandedTicketId` state,\n  // no auto-open effect, no re-open guard — opening, closing, deep-linking, and\n  // sharing a URL all flow through the same param.\n  const setOpenTicket = useCallback(\n    (externalId: string | null) => {\n      const params = new URLSearchParams(searchParams.toString());\n      if (externalId) params.set(TICKET_OPEN_PARAM, externalId);\n      else params.delete(TICKET_OPEN_PARAM);\n      const qs = params.toString();\n      router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });\n    },\n    [searchParams, router, pathname],\n  );\n\n  const { tickets, isLoading, isFetching, error, refetch, totalPages } = useTicketsList({\n    // `sessionEmail` is drilled in from the parent — see the same\n    // pattern + race-cause rationale documented in\n    // `HelpCenterCreateForm.sessionName/sessionEmail`. Calling\n    // `useChatIdentity` inside `useTicketsList` would race the\n    // parent's already-resolved identity and produce an empty-state\n    // flash on first render.\n    customerEmail: sessionEmail,\n    search,\n    status,\n    page,\n    // No interval polling (deleted 2026-08): `TicketLiveProvider`\n    // invalidates `['tickets']` on stream events; focus/mount refetch\n    // covers hosts without a stream. Disclosed trade-off: a bare\n    // status/pipeline change with no accompanying reply has no live\n    // path until the next event/focus/reconnect.\n  });\n\n  // Open state DERIVED from the URL param. `?ticket=` carries the user-facing\n  // `external_id`; map it to the internal row id the card matches on. Resolves\n  // to null until the ticket lands in the fetched list (deep-link cold load) and\n  // auto-collapses if the open ticket disappears (e.g. TICKET_NOT_FOUND removal).\n  const expandedTicketId = (ticketParam && tickets.find(t => t.external_id === ticketParam)?.id) || null;\n\n  // Optimistic cache management. Kept LOCAL (not in the query cache) so\n  // a refetch (e.g. URL-filter change) doesn't blow away pending\n  // placeholders. Merged view is `[...optimistic, ...server]` so\n  // placeholders sit at the top until they're explicitly removed.\n  const prependOptimistic = useCallback((placeholder: OptimisticTicket) => {\n    setOptimisticTickets(prev => [placeholder, ...prev]);\n  }, []);\n  const removeOptimistic = useCallback((placeholderId: string) => {\n    setOptimisticTickets(prev => prev.filter(t => t.id !== placeholderId));\n    // No drawer-collapse needed: optimistic placeholders have no `external_id`,\n    // so they can never be the URL-derived open ticket.\n  }, []);\n  const removeTicketFromCache = useCallback(\n    (ticketId: string) => {\n      // Every cache slot under the ['tickets'] prefix — the queryKey\n      // includes search + status + page + pageSize segments so a bare\n      // write would miss most slots.\n      //\n      // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT\n      // a bare `TicketData[]`. The previous version called `.filter()`\n      // directly on the object — silently crashing only on the rare\n      // TICKET_NOT_FOUND path; the prod regression that landed\n      // 2026-05-29 surfaced the same shape mismatch in the\n      // close/reopen optimistic-update path. Project, filter, reassemble.\n      queryClient.setQueriesData<TicketsCacheSlot | undefined>({ queryKey: ['tickets'] }, prev => {\n        if (!prev || !Array.isArray(prev.tickets)) return prev;\n        const nextTickets = prev.tickets.filter(t => t.id !== ticketId);\n        if (nextTickets.length === prev.tickets.length) return prev;\n        return { ...prev, tickets: nextTickets };\n      });\n      // The drawer auto-collapses on its own: once the ticket leaves the list,\n      // the URL-derived `expandedTicketId` finds no match → null. No state to clear.\n    },\n    [queryClient],\n  );\n\n  const actions = useTicketActions({\n    prependOptimistic,\n    removeOptimistic,\n    removeTicketFromCache,\n    toast,\n    onSupportSystemDown: () => setSupportSystemDown(true),\n  });\n\n  // Toggle = write the URL param (open) or clear it (close). The clicked card's\n  // internal id maps to its `external_id` for the param; optimistic rows (no\n  // external_id) aren't expandable so they short-circuit. This is the ONE open\n  // path — a click, a deep link, and a shared URL are indistinguishable.\n  const toggleRow = useCallback(\n    (id: string) => {\n      const t = tickets.find(x => x.id === id);\n      if (!t?.external_id) return;\n      setOpenTicket(t.external_id === ticketParam ? null : t.external_id);\n    },\n    [tickets, ticketParam, setOpenTicket],\n  );\n\n  const merged: AnyTicket[] = [...optimisticTickets, ...tickets];\n\n  // Deep-link hash dispatch — `/tickets#ticket-<external_id>` from a\n  // chat card (or any other in-app link). The `?ticket=<external_id>`\n  // query param keeps owning drawer auto-open; this hook owns the\n  // scroll-to-row independently. Both can fire on the same URL\n  // (`/tickets?ticket=X#ticket-X`) — drawer opens AND row scrolls into\n  // view. Shared `useScrollToHash` polls until the row mounts (handles\n  // the SWR fetch race), uses the canonical `scrollElementIntoView` tween.\n  useScrollToHash(tickets, { headerOffset: STICKY_HEADER_OFFSET_PX });\n  const hasActiveFilters = search !== '' || (status !== '' && status !== 'all');\n  const hasResults = merged.length > 0;\n\n  // Form is the canonical lib `<ContactForm>` (NOT a new ticket-specific\n  // form) — we hide every contact-only field, supply the customer's\n  // identity from `useChatIdentity` so Zod's name+email validators\n  // pass, slot a Subject `<Input>` into the new `extraTopField`\n  // position, and forward submission through `actions.submitTicket`.\n  // Same primitives, same wrapper styling, same visual treatment as\n  // every other primary form in the app.\n  const form = (\n    <HelpCenterCreateForm\n      actions={actions}\n      sessionName={sessionName}\n      sessionEmail={sessionEmail}\n      supportSystemDown={supportSystemDown}\n    />\n  );\n\n  const body = (\n    <div className=\"flex w-full flex-col gap-[40px]\">\n      {error && (\n        <div className=\"flex w-full flex-col items-center gap-3 rounded-[6px] border border-ods-border bg-ods-card p-[40px] text-center\">\n          <p className=\"text-ods-error text-h6\">Couldn&rsquo;t load your tickets. {error.message}</p>\n          <Button type=\"button\" variant=\"accent\" onClick={() => refetch()}>\n            Retry\n          </Button>\n        </div>\n      )}\n\n      {!error && (\n        <div className=\"w-full\">\n          {isLoading ? (\n            <DevCardRowSkeletonList />\n          ) : !hasResults && isFetching ? (\n            // Bridge state — background refetch in flight and the\n            // optimistic placeholder was just removed by the mutation\n            // callback. Without this branch \"No tickets yet\" would flash\n            // for ~50ms between `removeOptimistic` and the server\n            // response landing.\n            <DevCardRowSkeletonList rows={1} />\n          ) : !hasResults ? (\n            hasActiveFilters ? (\n              <EmptyState\n                type=\"search\"\n                title=\"No tickets found\"\n                description=\"No tickets match your current filters. Try clearing them or broadening your search.\"\n                showCTA\n                ctaText=\"Reset filters\"\n                onCtaClick={() => {\n                  const params = new URLSearchParams(searchParams.toString());\n                  params.delete('search');\n                  params.delete('status');\n                  router.replace(`${pathname}?${params.toString()}`, { scroll: false });\n                }}\n              />\n            ) : (\n              <EmptyState\n                type=\"generic\"\n                title=\"No tickets yet\"\n                description=\"Open one above to start the conversation with the support team.\"\n                showCTA={false}\n              />\n            )\n          ) : (\n            // `overflow-clip` (NOT `overflow-hidden`) — both visually\n            // clip the rounded corners, but `hidden` makes the element\n            // a \"scroll container\" per CSSOM spec, which causes\n            // `scrollIntoView` calls inside (`<HelpCenterCard>` click\n            // handlers) to try scrolling THIS div (can't, overflow\n            // hidden) instead of bubbling up to the window. `clip`\n            // keeps the visual clip but NOT the scroll-container\n            // status, so click-to-scroll actually moves the page.\n            <div className=\"w-full overflow-clip rounded-[6px] border border-ods-border bg-ods-card\">\n              {merged.map(ticket => (\n                <HelpCenterCard\n                  key={ticket.id}\n                  id={devSectionAnchorId('ticket', ticket.external_id)}\n                  ticket={ticket}\n                  expanded={expandedTicketId === ticket.id}\n                  onToggle={toggleRow}\n                  busy={isOptimistic(ticket) ? false : actions.isRowBusy(ticket.id)}\n                  supportSystemDown={supportSystemDown}\n                  onSendMessage={actions.sendMessage}\n                  onClose={actions.closeTicket}\n                  onReopen={actions.reopenTicket}\n                  onActionCollapsed={() => setOpenTicket(null)}\n                  replyError={actions.replyErrorFor(ticket.external_id)}\n                  onClearReplyError={() => actions.clearReplyError(ticket.external_id)}\n                />\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Pagination — `<UnifiedPagination>` owns the URL `?page=N`\n          rewrite on click; we just feed it the server-echoed current\n          page + totalPages. Hidden when there's at most one page so\n          the list doesn't reserve vertical space when it isn't\n          actionable. */}\n      {!error && totalPages > 1 && <UnifiedPagination currentPage={page} totalPages={totalPages} />}\n    </div>\n  );\n\n  return (\n    <DevSectionPage sectionKey=\"tickets\" backButton={backButton} title={title} shell={shell} preControls={form}>\n      {body}\n    </DevSectionPage>\n  );\n}\n","'use client';\n\n/**\n * `<HelpCenterCard />` — single ticket row inside the Help Center list.\n *\n * Visual chrome is 1:1 with the delivery list (`<DeliveryTable>`) via\n * the shared `<DevCardRowContent>` primitive. The differentiator: the\n * entire summary row is a `<button>` that toggles an expanded drawer\n * beneath it (`<TicketDetailDrawer />` — same composer + timeline +\n * close/reopen affordances as the embedded `<TicketCenter />`).\n *\n * Click target: the summary row only. Clicks inside the expanded\n * drawer (composer textarea, attachment chips, close-dialog button)\n * don't propagate up to the row's toggle handler because the drawer\n * is a SIBLING of the toggle button, not nested inside it.\n */\n\nimport { useCallback, useEffect, useRef } from 'react';\nimport { formatRelativeTime } from '../../utils/date-utils';\nimport { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';\nimport { scrollElementIntoView } from '../../utils/scroll-into-view';\nimport { getStatusColorScheme } from '../chat/utils/agent-status-message';\nimport { DevCardRowContent } from '../shared/dev-section/dev-card-row';\nimport { StatusBadge, type StatusBadgeProps } from '../ui';\nimport { TicketDetailDrawer, type TicketDetailDrawerProps } from './ticket-detail-drawer';\nimport { useOptionalTicketLive } from './ticket-live-provider';\nimport type { AnyTicket } from './types';\nimport { isOptimistic } from './types';\n\nexport interface HelpCenterCardProps {\n  ticket: AnyTicket;\n  expanded: boolean;\n  onToggle: (id: string) => void;\n  busy: boolean;\n  supportSystemDown: boolean;\n  onSendMessage: TicketDetailDrawerProps['onSendMessage'];\n  onClose: TicketDetailDrawerProps['onClose'];\n  onReopen: TicketDetailDrawerProps['onReopen'];\n  onActionCollapsed: () => void;\n  /** Persisted reply-failure banner — forwarded to the drawer. Parent\n   *  (`HelpCenterList`) reads via `actions.replyErrorFor(external_id)`. */\n  replyError?: TicketDetailDrawerProps['replyError'];\n  onClearReplyError?: TicketDetailDrawerProps['onClearReplyError'];\n  /** DOM `id` applied to the row's outer element. Parent (`HelpCenterList`)\n   *  sets `ticket-<external_id>` so `useScrollToHash` can deep-link from\n   *  a chat card's `?ticket=<id>#ticket-<id>` URL. The sticky-header\n   *  offset is already baked in via `STICKY_HEADER_OFFSET_PX` so the row\n   *  lands BELOW the chrome regardless of whether `id` is set. */\n  id?: string;\n}\n\nexport function HelpCenterCard({\n  ticket,\n  expanded,\n  onToggle,\n  busy,\n  supportSystemDown,\n  onSendMessage,\n  onClose,\n  onReopen,\n  onActionCollapsed,\n  replyError,\n  onClearReplyError,\n  id,\n}: HelpCenterCardProps) {\n  const optimistic = isOptimistic(ticket);\n  const rawStatus = (ticket.status ?? 'OPEN').toUpperCase();\n  const priority = (ticket.priority ?? '').toUpperCase();\n\n  const relativeUpdated = ticket.hubspot_updated_at ? formatRelativeTime(ticket.hubspot_updated_at) : 'recently';\n\n  // Use `||` not `??` so an EMPTY-STRING subject (legacy rows, partial\n  // server data) falls through to the placeholder instead of rendering\n  // a blank h3.\n  const title = (ticket.subject || '').trim() || '(untitled)';\n  const subtitle = `UPDATED ${relativeUpdated}, #${ticket.external_id || '—'}${\n    ticket.pipeline_stage_label ? `, ${ticket.pipeline_stage_label}` : ''\n  }`;\n  const description = ticket.preview ?? ticket.body ?? '';\n\n  // Optimistic placeholders show as a row but aren't expandable — the\n  // real external_id hasn't landed so the drawer's `useTicketEngagements`\n  // would have nothing to fetch, and action targets would be undefined.\n  const isExpandable = !optimistic;\n  const isExpanded = expanded && isExpandable;\n\n  const rowRef = useRef<HTMLDivElement | null>(null);\n  // Click only toggles — the scroll-to-top is deferred to the effect below.\n  const handleClick = useCallback(() => {\n    onToggle(ticket.id);\n  }, [onToggle, ticket.id]);\n\n  // Smooth-scroll the row to the top once the drawer has expanded — in an\n  // effect keyed on `isExpanded` (NOT the click handler, which runs before\n  // React commits the drawer, when the page isn't yet tall enough to scroll).\n  //\n  // The cancellation-proof motion lives in the shared `scrollElementIntoView`\n  // helper (self-driven rAF tween, instant per-frame writes, target recomputed\n  // each frame). It is immune to the browser SCROLL ANCHORING that cancelled the\n  // old native `window.scrollTo({behavior:'smooth'})` on every open after the\n  // first — the bug where smooth \"only worked once\" because anchoring is\n  // suppressed at scrollY=0 (first open) but aborts the native smooth scroll\n  // from any non-zero offset (every later open). See that util for the full\n  // mechanics. One leading rAF so the expanded drawer has committed its height\n  // before the first measurement; the tween then tracks the row to its resting\n  // position as the page finishes growing. Cleanup cancels on collapse/unmount.\n  useEffect(() => {\n    if (!isExpanded) return undefined;\n    const raf = requestAnimationFrame(() => {\n      scrollElementIntoView(rowRef.current, {\n        headerOffset: STICKY_HEADER_OFFSET_PX,\n      });\n    });\n    return () => cancelAnimationFrame(raf);\n  }, [isExpanded]);\n\n  // Unread replies for this row — read from the provider's single summary\n  // map (missing key / no provider = 0). The provider masks the open\n  // ticket to 0 and zeroes on markRead, so no local state here.\n  const live = useOptionalTicketLive();\n  const unreadCount = (!optimistic && ticket.external_id && live?.unreadByTicket[ticket.external_id]) || 0;\n\n  const rightBadges = (\n    <>\n      {unreadCount > 0 && (\n        <StatusBadge\n          text={`${unreadCount} NEW`}\n          colorScheme=\"warning\"\n          variant=\"card\"\n          className=\"border border-ods-border\"\n        />\n      )}\n      <StatusBadge\n        text={rawStatus}\n        colorScheme={getStatusColorScheme(rawStatus)}\n        variant=\"card\"\n        className=\"border border-ods-border\"\n      />\n      {priority && (\n        <StatusBadge\n          text={priority}\n          colorScheme={mapPriorityScheme(priority)}\n          variant=\"card\"\n          className=\"border border-ods-border\"\n        />\n      )}\n    </>\n  );\n\n  return (\n    <div\n      ref={rowRef}\n      id={id}\n      style={{ scrollMarginTop: STICKY_HEADER_OFFSET_PX }}\n      className={`border-b border-ods-border last:border-b-0 ${optimistic ? 'opacity-60' : ''}`}\n      aria-busy={optimistic || undefined}\n    >\n      <button\n        type=\"button\"\n        onClick={isExpandable ? handleClick : undefined}\n        disabled={!isExpandable}\n        aria-expanded={isExpandable ? isExpanded : undefined}\n        aria-controls={isExpanded ? `help-center-drawer-${ticket.id}` : undefined}\n        className=\"w-full p-[12px] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent disabled:cursor-default md:p-[16px]\"\n      >\n        <DevCardRowContent\n          title={title}\n          subtitle={subtitle}\n          description={description}\n          emptyDescription=\"No description provided\"\n          rightBadges={rightBadges}\n        />\n      </button>\n\n      {isExpanded && (\n        <div id={`help-center-drawer-${ticket.id}`}>\n          <TicketDetailDrawer\n            ticket={ticket}\n            busy={busy}\n            supportSystemDown={supportSystemDown}\n            onSendMessage={onSendMessage}\n            onClose={onClose}\n            onReopen={onReopen}\n            onActionCollapsed={onActionCollapsed}\n            replyError={replyError}\n            onClearReplyError={onClearReplyError}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** Ticket priority → StatusBadge colorScheme. HIGH / URGENT → red,\n *  MEDIUM → yellow, LOW / unknown → default-muted. Kept local because\n *  the central `getStatusColorScheme` is keyed on workflow status, not\n *  severity, and conflating them would mis-render an \"OPEN\" status as\n *  a low-priority badge or vice-versa. */\nfunction mapPriorityScheme(priority: string): NonNullable<StatusBadgeProps['colorScheme']> {\n  if (priority === 'HIGH' || priority === 'URGENT') return 'error';\n  if (priority === 'MEDIUM') return 'warning';\n  return 'default';\n}\n","'use client';\n\n/**\n * `<HelpCenterCreateForm />` — thin wrapper around the canonical\n * `<ContactForm />`. Does NOT reimplement form layout / validation /\n * primitives — it just preconfigures `<ContactForm />` for ticket\n * creation:\n *\n *   - Hides every contact-only field (name + email from session,\n *     companySize / referralSource / helpCategory irrelevant).\n *   - Slots a Subject `<Input>` into `extraTopField` (Subject isn't\n *     part of `ContactSchema`; the wrapper manages it locally and\n *     reads the value back in `onCustomSubmit`).\n *   - Pre-fills the hidden name / email / helpCategory from\n *     `useChatIdentity` so Zod's required-field validators pass even\n *     though those inputs aren't rendered.\n *   - Wires `onCustomSubmit` to `actions.submitTicket(...)` so the\n *     ticket lands through the same optimistic-placeholder flow the\n *     rest of the Help Center uses.\n *\n * Why a wrapper instead of inlining `<ContactForm />` directly inside\n * `<HelpCenterList />`: the Subject input needs local state + error\n * state + validation that conceptually pairs with the form, not the\n * orchestrator. Keeping that tiny state machine in its own file keeps\n * `<HelpCenterList />` focused on list/state/pagination concerns.\n */\n\nimport { useState, type ChangeEvent } from 'react';\nimport { ContactForm } from '../contact';\nimport { Input, Label } from '../ui';\nimport { RequiredMark } from '../ui/required-mark';\nimport type { UseTicketActionsReturn } from './hooks/use-ticket-actions';\n\nconst SUBJECT_MAX_CHARS = 200;\n\nexport interface HelpCenterCreateFormProps {\n  /** The full actions bag from `useTicketActions` — we read\n   *  `submitTicket` from it. Passing the whole bag (rather than just\n   *  the one method) keeps the wiring shape consistent with other\n   *  composition points (e.g. `<HelpCenterCard>` takes individual\n   *  action callbacks because the drawer needs four of them; the form\n   *  only needs one but the parent already has the bag in scope). */\n  actions: UseTicketActionsReturn;\n  /** Authoritative session identity, resolved by the parent\n   *  (`HelpCenterList`) which already gates rendering on\n   *  `identity.isLoading === false`. Passing these in (instead of\n   *  calling `useChatIdentity` again here) avoids a subtle race:\n   *  `useChatIdentity` is a plain `useState`+`useEffect` hook (no\n   *  shared cache), so a second call inside this child would mount\n   *  with `user = null` and a stale `sessionEmail = ''`, locking\n   *  react-hook-form's `defaultValues.email` to an empty string for\n   *  the lifetime of the form — Zod then rejects the submit silently. */\n  sessionName: string;\n  sessionEmail: string;\n  /** Disables every input + button when the support backend (HubSpot)\n   *  is down. Wired from the parent's\n   *  `useTicketActions.onSupportSystemDown` flag. */\n  supportSystemDown?: boolean;\n}\n\n/**\n * Loading placeholder that mirrors `<HelpCenterCreateForm>`'s outer\n * dimensions PIXEL-FOR-PIXEL so the page chrome doesn't shift when\n * identity resolves and the real form mounts.\n *\n * Verified against live DOM bounding-rect measurements at the lg\n * breakpoint — every section's top + height matches the real form to\n * the pixel (skeleton wrapper 556px == real wrapper 556px). Each\n * section uses the SAME flex / spacing / padding classes the real\n * form uses, so the gaps propagate identically; only the inner\n * content swaps an `<Input>` / `<Textarea>` / `<Button>` for a\n * same-sized animated `bg-ods-border` bar.\n *\n *   wrapper          → `p-6 md:p-8 lg:p-10` + border + rounded-3xl\n *   heading area     → 56px (`mb-6 md:mb-8` container, h-10 inner bar\n *                       + `mb-3 md:mb-4` = 40 + 16)\n *   subject section  → 79px (`h-[27px]` label + `mb-1` (4px) + h-12\n *                       input = 27 + 4 + 48)\n *   message section  → 127px (`h-[27px]` label + `mb-1` + h-24\n *                       textarea = 27 + 4 + 96)\n *   attachments row  → 28px (h-7 add button + helper label)\n *   footer           → 56px (h-12 button + `pt-2 mt-auto`)\n *   between sections → `space-y-4 md:space-y-6` (16/24px)\n *\n * One non-obvious detail: the real `<ContactForm>` renders 4\n * `<input type=\"hidden\">` registrations BEFORE the visible Subject\n * section (for the hidden name/email/helpCategory/message fields).\n * Tailwind's `space-y-*` rule (`:not([hidden]) ~ :not([hidden])`)\n * counts `type=\"hidden\"` inputs as siblings, so Subject gets a 24px\n * top margin. The skeleton mirrors those 4 hidden inputs exactly so\n * the spacing rule fires identically — without them the whole stack\n * shifts up 24px on every page load.\n */\nexport function HelpCenterCreateFormSkeleton() {\n  return (\n    <div className=\"flex h-full flex-col rounded-2xl border border-ods-border p-6 md:rounded-3xl md:p-8 lg:p-10\">\n      {/* Heading container — mirrors `mb-6 md:mb-8` + h2 with its own\n          `mb-3 md:mb-4` and `text-h2` height (32px font, line-height\n          ~1.25 → 40px). h-10 bar matches the rendered h2 height. */}\n      <div className=\"mb-6 md:mb-8\">\n        <div className=\"mb-3 h-10 w-72 animate-pulse rounded bg-ods-border md:mb-4\" />\n      </div>\n\n      {/* Form body — same `space-y-4 md:space-y-6` gap stack.\n          IMPORTANT: the real `<ContactForm>` prepends 4\n          `<input type=\"hidden\">` registrations for the hidden\n          name/email/helpCategory/message fields (see contact-form.tsx).\n          `space-y-*` uses `:not([hidden]) ~ :not([hidden])` — `type=\"hidden\"`\n          inputs aren't excluded — so those hidden inputs ARE counted as\n          siblings, and the visible Subject section gets a 24px top\n          margin. The skeleton mirrors that exact structure with the\n          same 4 hidden inputs so the Subject placeholder lands at the\n          same Y as the real Subject input. Removing them would shift\n          the whole stack up by 24px on every page load. */}\n      <div className=\"flex flex-grow flex-col space-y-4 md:space-y-6\">\n        <input type=\"hidden\" aria-hidden />\n        <input type=\"hidden\" aria-hidden />\n        <input type=\"hidden\" aria-hidden />\n        <input type=\"hidden\" aria-hidden />\n        {/* Subject section — `flex flex-col` matches real form. Label\n            bar uses arbitrary `h-[27px]` to match the live Label\n            component (18px font * 1.5 line-height = 27px) and `mb-1`\n            (4px) which is Tailwind's default `mb-1`. Total section\n            height: 27 + 4 + 48 (h-12 input) = 79px, identical to\n            real form. */}\n        <div className=\"flex flex-col\">\n          <div className=\"mb-1 h-[27px] w-20 animate-pulse rounded bg-ods-border\" />\n          <div className=\"h-12 w-full animate-pulse rounded bg-ods-border\" />\n        </div>\n\n        {/* Message section — `flex flex-col flex-grow` matches real\n            form (textarea fills remaining vertical space inside the\n            wrapper). h-24 bar (96px) = textarea's natural rendered\n            height when the wrapper has the standard list + footer\n            below it. Same `h-[27px]` + `mb-1` Label pattern as Subject:\n            27 + 4 + 96 = 127px, identical to real form. */}\n        <div className=\"flex flex-grow flex-col\">\n          <div className=\"mb-1 h-[27px] w-32 animate-pulse rounded bg-ods-border\" />\n          <div className=\"h-24 w-full flex-grow animate-pulse rounded bg-ods-border\" />\n        </div>\n\n        {/* Attachments row — mirrors the real `flex flex-col gap-2`\n            container with chip strip (empty when no files staged) +\n            the h-7 add button + helper text. */}\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex items-center gap-2\">\n            <div className=\"h-7 w-7 shrink-0 animate-pulse rounded bg-ods-border\" />\n            <div className=\"h-4 w-40 animate-pulse rounded bg-ods-border\" />\n          </div>\n        </div>\n\n        {/* Footer — same `pt-2 mt-auto` so it sticks to the bottom.\n            Button bar is h-12 to match the real `<Button>` height\n            (48px). */}\n        <div className=\"mt-auto flex w-full flex-col items-center justify-end gap-4 pt-2 md:flex-row md:gap-6\">\n          <div className=\"h-4 w-72 animate-pulse rounded bg-ods-border\" />\n          <div className=\"h-12 w-32 animate-pulse rounded bg-ods-border\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function HelpCenterCreateForm({\n  actions,\n  sessionName,\n  sessionEmail,\n  supportSystemDown = false,\n}: HelpCenterCreateFormProps) {\n  const [subject, setSubject] = useState('');\n  const [subjectError, setSubjectError] = useState<string | null>(null);\n\n  // Subject input — slotted into `<ContactForm>`'s new `extraTopField`\n  // position. Local state + error so the input behaves like the\n  // schema-driven siblings.\n  const subjectField = (\n    <div className=\"flex flex-col\">\n      <Label htmlFor=\"help-center-subject\">\n        Subject\n        <RequiredMark />\n      </Label>\n      <Input\n        id=\"help-center-subject\"\n        type=\"text\"\n        value={subject}\n        onChange={(e: ChangeEvent<HTMLInputElement>) => {\n          setSubject(e.target.value);\n          if (subjectError) setSubjectError(null);\n        }}\n        placeholder=\"Briefly describe what's going on\"\n        maxLength={SUBJECT_MAX_CHARS}\n        aria-invalid={!!subjectError}\n        aria-required\n        aria-describedby={subjectError ? 'help-center-subject-error' : undefined}\n        disabled={supportSystemDown}\n        className=\"h-12 border-ods-border bg-ods-card px-3 text-ods-text-primary placeholder-ods-text-secondary\"\n      />\n      {subjectError && (\n        <span id=\"help-center-subject-error\" className=\"mt-1 text-ods-error text-h6\">\n          {subjectError}\n        </span>\n      )}\n    </div>\n  );\n\n  return (\n    <ContactForm\n      title=\"Open a new ticket\"\n      footerText=\"The support team typically responds within one business day.\"\n      hideFields={['name', 'email', 'companySize', 'referralSource', 'helpCategory']}\n      defaultValues={{\n        name: sessionName,\n        email: sessionEmail,\n        helpCategory: 'Support Request',\n      }}\n      extraTopField={subjectField}\n      submitLabel=\"Open ticket\"\n      attachmentsEnabled\n      onCustomSubmit={async (data, attachments) => {\n        const trimmedSubject = subject.trim();\n        if (!trimmedSubject) {\n          setSubjectError('Subject is required');\n          // Throw so `<ContactForm>`'s catch path doesn't `reset()` —\n          // user keeps their typed message body and just adds a subject.\n          throw new Error('SUBJECT_REQUIRED');\n        }\n        setSubjectError(null);\n        const ok = await actions.submitTicket({\n          subject: trimmedSubject,\n          content: data.message,\n          attachments: attachments.length > 0 ? attachments : undefined,\n        });\n        if (ok) {\n          setSubject('');\n        } else {\n          // Same as above — keep inputs for retry. The toast is already\n          // surfaced by `useTicketActions`.\n          throw new Error('TICKET_SUBMIT_FAILED');\n        }\n      }}\n    />\n  );\n}\n"]}