{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-TK6RPD5T.cjs","../src/components/tickets/ticket-center.tsx","../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/hooks/use-tickets-list.ts","../src/components/tickets/hooks/use-ticket-actions.ts","../src/components/tickets/help-center-list.tsx","../src/components/tickets/help-center-card.tsx","../src/components/tickets/help-center-create-form.tsx"],"names":["jsx","jsxs","CollapsibleContent","useState","useCallback","toast"],"mappings":"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AA;AACf,EAAA;AAEO,EAAA;AACE,IAAA;AACL,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACF,IAAA;AACA,IAAA;AACE,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACF,IAAA;AACF,EAAA;AACF;AAK+C;AAGkC,EAAA;AAChE,IAAA;AACD,IAAA;AACI,IAAA;AAClB,EAAA;AACF;AAM4E;AAClC,EAAA;AACpB,IAAA;AACX,MAAA;AACI,QAAA;AACK,UAAA;AACD,UAAA;AACU,UAAA;AACC,UAAA;AACtB,QAAA;AACG,MAAA;AACI,QAAA;AACK,UAAA;AACD,UAAA;AACU,UAAA;AACC,UAAA;AACtB,QAAA;AACG,MAAA;AACI,QAAA;AACK,UAAA;AACD,UAAA;AACU,UAAA;AACC,UAAA;AACtB,QAAA;AACG,MAAA;AACI,QAAA;AACK,UAAA;AACD,UAAA;AACU,UAAA;AACC,UAAA;AACtB,QAAA;AACmB,MAAA;AAC0C,QAAA;AACW,QAAA;AACjE,QAAA;AACK,UAAA;AAGN,UAAA;AACe,UAAA;AACC,UAAA;AAC6B,UAAA;AACnD,QAAA;AACF,MAAA;AACK,MAAA;AACI,QAAA;AACK,UAAA;AACD,UAAA;AACU,UAAA;AACC,UAAA;AACtB,QAAA;AACG,MAAA;AACI,QAAA;AACK,UAAA;AAER,UAAA;AACiB,UAAA;AACC,UAAA;AACtB,QAAA;AACG,MAAA;AACI,QAAA;AACK,UAAA;AAER,UAAA;AACiB,UAAA;AACC,UAAA;AACtB,QAAA;AACG,MAAA;AACI,QAAA;AACK,UAAA;AAER,UAAA;AACiB,UAAA;AACC,UAAA;AACtB,QAAA;AACG,MAAA;AACI,QAAA;AACK,UAAA;AAER,UAAA;AACiB,UAAA;AACC,UAAA;AACtB,QAAA;AACF,MAAA;AACS,QAAA;AACC,UAAA;AACkB,UAAA;AACL,UAAA;AACC,UAAA;AACtB,QAAA;AACJ,IAAA;AACF,EAAA;AACO,EAAA;AACC,IAAA;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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-TK6RPD5T.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 { useCallback, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { Card } from '../ui/card'\nimport { Button } from '../ui/button'\nimport { Skeleton } from '../ui/skeleton'\nimport { EmptyState } from '../empty-state'\nimport { RefreshCw } from 'lucide-react'\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity'\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 { TicketOpenForm } from './ticket-open-form'\nimport { TicketRow } from './ticket-row'\nimport { useTicketsList } from './hooks/use-tickets-list'\nimport { useTicketActions } from './hooks/use-ticket-actions'\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 (\n    <TicketCenterAuthed\n      toast={toast}\n      sessionEmail={identity.user.email}\n    />\n  )\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>(\n        { queryKey: ['tickets'] },\n        (prev) => (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-h5 text-ods-text-secondary\">\n            Your Current Tickets\n          </p>\n          <div className=\"flex items-center gap-3 text-h6 text-ods-text-secondary\">\n            {lastUpdatedAt && (\n              <span>Updated {formatRelativeTime(new Date(lastUpdatedAt))}</span>\n            )}\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=\"h-7 w-48 mb-4\" />\n        <Skeleton className=\"h-10 w-full mb-3\" />\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=\"h-20 px-4 flex items-center gap-4 border-b border-ods-border last:border-b-0\">\n          <div className=\"flex-1 flex 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 * 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 { Card } from '../ui/card'\nimport { Input } from '../ui/input'\nimport { Textarea } from '../ui/textarea'\nimport { Button } from '../ui/button'\nimport {\n  ChatAttachmentAddButton,\n  ChatAttachmentChipStrip,\n} from '../chat/chat-attachment-bar'\nimport { useChatAttachments } from '../chat/hooks/use-chat-attachments'\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: import('../chat/utils/chat-attachment-markdown').ChatAttachment[] }) => Promise<boolean>\n  isSubmitting: boolean\n  supportSystemDown: boolean\n}\n\nexport function TicketOpenForm({\n  onSubmit,\n  isSubmitting,\n  supportSystemDown,\n}: 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: React.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 md:flex-row gap-6\">\n        <div className=\"flex-1 min-w-0 md:max-w-md\">\n          <h2 className=\"text-h2 text-ods-text-primary mb-2\">\n            Need Support?\n          </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\n            below — we&apos;ll follow up shortly.\n          </p>\n          {supportSystemDown && (\n            <p className=\"mt-4 text-h6 text-ods-error\">\n              Support system temporarily unavailable. Please try again shortly.\n            </p>\n          )}\n        </div>\n\n        <div className=\"flex-1 min-w-0 flex flex-col gap-4\">\n          <div>\n            <label\n              htmlFor=\"ticket-subject\"\n              className=\"block text-h6 text-ods-text-primary mb-1\"\n            >\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\n              htmlFor=\"ticket-content\"\n              className=\"block text-h6 text-ods-text-primary mb-1\"\n            >\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\n                className={`mt-1 text-h6 text-right ${\n                  overCap ? 'text-ods-error' : 'text-ods-text-secondary'\n                }`}\n              >\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\n              type=\"submit\"\n              disabled={!canSubmit}\n              loading={isSubmitting}\n            >\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 {\n  Collapsible,\n  CollapsibleContent,\n} from '../collapsible'\nimport {\n  ChatTicketItem,\n  type ChatTicketItemData,\n} from '../chat/entity-cards/chat-ticket-item'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { scrollElementIntoView } from '../../utils/scroll-into-view'\nimport {\n  TicketDetailDrawer,\n  type TicketDetailDrawerProps,\n} 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(\n          'div[id^=\"ticket-drawer-\"]',\n        )\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\n      ? formatRelativeTime(ticket.hubspot_updated_at)\n      : 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\n        open={expanded && !optimistic}\n        className=\"border-b border-ods-border last:border-b-0\"\n      >\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=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\"\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 { Button } from './../ui/button'\nimport { useChatIdentity } from './../chat/hooks/use-chat-identity'\nimport {\n  ChatMessageRow,\n  ChatMessageRowSkeleton,\n} from './../chat/chat-message-row'\nimport { EmptyState } from './../empty-state'\nimport {\n  TicketAttachmentsList,\n  type TicketAttachment,\n} from './../ui/ticket-attachments-list'\nimport { SquareAvatar } from './../ui/square-avatar'\nimport { formatRelativeTime } from './../../utils/date-utils'\nimport { useTicketEngagements } from './hooks/use-ticket-engagements'\nimport type {\n  TicketEngagementFile,\n} 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 {\n  AnyTicket,\n  TicketAssignedOwner,\n  MappedTicketActionError,\n} 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: (\n    ticket: TicketRef,\n    text: string,\n    attachments: import('./../chat/utils/chat-attachment-markdown').ChatAttachment[],\n  ) => 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  onActionCollapsed,\n  replyError,\n  onClearReplyError,\n}: TicketDetailDrawerProps) {\n  const isClosed = (ticket.status ?? '').toUpperCase() === 'CLOSED'\n  return (\n    <div className=\"bg-ods-card border-t border-ods-border px-4 py-4 flex flex-col gap-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 && (\n        <TicketLinkedDeliveryCard clickup={ticket.clickup} />\n      )}\n\n      <div>\n        <p className=\"text-h5 text-ods-text-secondary mb-2\">\n          Conversation\n        </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 && (\n          <ReplyFailureBanner\n            error={replyError}\n            onDismiss={onClearReplyError ?? (() => undefined)}\n          />\n        )}\n        {isClosed ? (\n          <ReopenAction\n            ticketRef={{ id: ticket.id, external_id: ticket.external_id }}\n            busy={busy}\n            supportSystemDown={supportSystemDown}\n            onReopen={onReopen}\n            onActionCollapsed={onActionCollapsed}\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 =\n  '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\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.split(TURN_SEPARATOR_RE).map((t) => t.trim()).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 =\n    bodyTurns.length > 0 &&\n    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 &&\n    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) ||\n    ticketCustomerName ||\n    viewerName ||\n    identity.user?.email ||\n    'You'\n  const customerAvatar = isViewerTheCustomer\n    ? identity.user?.avatarUrl ?? undefined\n    : 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(\n  files: TicketEngagementFile[],\n): TicketAttachment[] {\n  return files.map((f) => ({\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:\n      f.url && (f.mime?.startsWith('image/') ?? false) ? f.url : undefined,\n    onDownload: f.url\n      ? () => window.open(f.url!, '_blank', 'noopener,noreferrer')\n      : undefined,\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  onActionCollapsed,\n}: {\n  ticketRef: TicketRef\n  busy: boolean\n  supportSystemDown: boolean\n  onReopen: TicketDetailDrawerProps['onReopen']\n  onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']\n}) {\n  const handleReopen = async () => {\n    // Intentionally do NOT call `onActionCollapsed()` here. 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({\n  error,\n  onDismiss,\n}: {\n  error: MappedTicketActionError\n  onDismiss: () => void\n}) {\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-h6 text-ods-error\"\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 px-2 py-0.5 text-h6 uppercase text-ods-error hover:bg-ods-error-secondary border-transparent\"\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({\n  assignedOwner,\n}: {\n  assignedOwner: TicketAssignedOwner | null\n}) {\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 =\n    trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)\n  return (\n    <div className=\"flex items-center gap-2 text-h6\">\n      <span className=\"text-h5 text-ods-text-secondary\">\n        Assigned to\n      </span>\n      {displayLabel ? (\n        <span className=\"flex items-center gap-1.5 text-ods-text-primary font-medium\">\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=\"text-ods-text-secondary italic\">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 =\n    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 =\n    enabled &&\n    !!externalTicketId &&\n    !externalTicketId.startsWith('temp-') // optimistic placeholders have no real id yet\n\n  const queryEnabled =\n    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:\n      fetchable &&\n      (identity.isLoading || (queryEnabled && query.data === undefined)),\n    isFetching: query.isFetching,\n    error: (query.error as Error | null) ?? 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 { DeliveryRow } from '../shared/delivery/delivery-row'\nimport type { DeliveryItem } from '../../types/delivery'\nimport type { TicketClickupSummary } from './types'\n\nexport interface TicketLinkedDeliveryCardProps {\n  clickup: TicketClickupSummary\n  className?: string\n}\n\nexport function TicketLinkedDeliveryCard({\n  clickup,\n  className,\n}: 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 ?? Date.now(),\n    dateClosed: clickup.date_closed,\n    clickupUrl: clickup.clickup_url ?? '',\n  }\n\n  return (\n    <div\n      className={`rounded-md border border-ods-border bg-ods-bg overflow-hidden ${className ?? ''}`}\n    >\n      <DeliveryRow\n        item={item}\n        href={clickup.delivery_href}\n        caption=\"Linked delivery\"\n      />\n    </div>\n  )\n}\n","'use client'\n\nimport { useCallback, useState } from 'react'\nimport { Button } from './../ui/button'\nimport { Textarea } from './../ui/textarea'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from './../ui/alert-dialog'\nimport { ChatInput } from './../chat/chat-input'\nimport {\n  ChatAttachmentAddButton,\n  ChatAttachmentChipStrip,\n} from './../chat/chat-attachment-bar'\nimport { useChatAttachments } from './../chat/hooks/use-chat-attachments'\nimport type { AnyTicket } from './types'\nimport { TICKET_TEXT_MAX_CHARS } from './types'\nimport type { TicketDetailDrawerProps, TicketRef } from './ticket-detail-drawer'\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\n  const ticketRef: TicketRef = { id: ticket.id, external_id: ticket.external_id }\n  const hasReadyFiles = attachments.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(), attachments.readyAttachments)\n      if (ok) attachments.clear()\n      return ok\n    },\n    // Depend on the reactive projections, not the whole bag (a fresh object each\n    // render). `readyAttachments` is memo-stable; `clear` is callback-stable.\n    [\n      onSendMessage,\n      ticket.id,\n      ticket.external_id,\n      attachments.readyAttachments,\n      attachments.clear,\n    ],\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 items-center gap-2 w-full\">\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=\"flex-1 min-w-0\" />\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=\"bg-ods-card border-ods-border\">\n          <AlertDialogHeader>\n            <AlertDialogTitle className=\"text-ods-text-primary\">\n              Close this ticket?\n            </AlertDialogTitle>\n            <AlertDialogDescription className=\"text-ods-text-secondary\">\n              Add an optional resolution note below. You can reopen the ticket\n              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=\"bg-transparent border-ods-border 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 * 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 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 =\n    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 = Math.max(1, Math.floor(filters.page ?? 1) || 1)\n  const pageSize = Math.max(1, Math.min(100, Math.floor(filters.pageSize ?? DEFAULT_PAGE_SIZE) || DEFAULT_PAGE_SIZE))\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 ?? Math.max(1, Math.ceil(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:\n      enabled &&\n      (data === undefined ||\n        (query.isFetching && (data?.tickets ?? []).length === 0)),\n    isFetching: query.isFetching,\n    error: (query.error as Error | null) ?? 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 * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch'\nimport { useOptionalTicketLive } from '../ticket-live-provider'\nimport type { ChatAttachment } from '../../chat/utils/chat-attachment-markdown'\nimport {\n  type AnyTicket,\n  type MappedTicketActionError,\n  type OptimisticTicket,\n  type TicketActionErrorCode,\n  type TicketData,\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\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 =\n    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  const formInFlightRef = useRef(false)\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<\n    Map<string, MappedTicketActionError>\n  >(() => new Map())\n  const setReplyError = useCallback(\n    (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    [],\n  )\n  const replyErrorFor = useCallback(\n    (externalId: string): MappedTicketActionError | null =>\n      replyErrorByTicket.get(externalId) ?? null,\n    [replyErrorByTicket],\n  )\n  const clearReplyError = useCallback(\n    (externalId: string) => setReplyError(externalId, null),\n    [setReplyError],\n  )\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    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 watcherControllersRef.current.values()) {\n        controller.abort()\n      }\n      watcherControllersRef.current.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) return false\n      formInFlightRef.current = true\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        formInFlightRef.current = false\n        setIsSubmittingForm(false)\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          } as unknown as Record<string, unknown>)\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 =\n            (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>(\n              { queryKey: ['tickets'] },\n              (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\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) =>\n      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    [\n      submitTicket,\n      sendMessage,\n      closeTicket,\n      reopenTicket,\n      isSubmittingForm,\n      isRowBusy,\n      replyErrorFor,\n      clearReplyError,\n    ],\n  )\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\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:\n            \"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:\n            '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:\n            '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:\n            \"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(\n  queryClient: ReturnType<typeof useQueryClient>,\n  expectedTicketId: string,\n): 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 (\n      data &&\n      Array.isArray(data.tickets) &&\n      data.tickets.some((t) => t.external_id === expectedTicketId)\n    ) {\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(\n  bodyCode: string | undefined,\n  status: number,\n): 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 * `<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 { useCallback, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useSearchParams, useRouter, usePathname } from '../../embed-shims'\nimport { Button } from '../ui'\nimport { EmptyState } from '../empty-state'\nimport { DevSectionPage } from '../shared/dev-section'\nimport { DevCardRowSkeletonList } from '../shared/dev-section/dev-card-row'\nimport { UnifiedPagination } from '../unified-pagination'\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity'\nimport { useScrollToHash } from '../../hooks/use-scroll-to-hash'\nimport { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav'\nimport { DEV_SECTION_PARAM_KEYS, devSectionAnchorId } from '../../utils/dev-sections/dev-section-param-keys'\nimport { toast as defaultToast } from '../../hooks/use-toast'\nimport { useTicketsList } from './hooks/use-tickets-list'\nimport { useTicketActions } from './hooks/use-ticket-actions'\nimport { HelpCenterCard } from './help-center-card'\nimport { HelpCenterCreateForm, HelpCenterCreateFormSkeleton } from './help-center-create-form'\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 rawPage = Number(searchParams.get('page'))\n  const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 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 =\n    (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>(\n        { queryKey: ['tickets'] },\n        (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      )\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=\"w-full flex flex-col gap-[40px]\">\n      {error && (\n        <div className=\"bg-ods-card border border-ods-border rounded-[6px] p-[40px] text-center w-full flex flex-col items-center gap-3\">\n          <p className=\"text-ods-error text-h6\">\n            Couldn&rsquo;t load your tickets. {error.message}\n          </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=\"bg-ods-card border border-ods-border rounded-[6px] overflow-clip w-full\">\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 && (\n        <UnifiedPagination currentPage={page} totalPages={totalPages} />\n      )}\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 { StatusBadge, type StatusBadgeProps } from '../ui'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { scrollElementIntoView } from '../../utils/scroll-into-view'\nimport { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav'\nimport { getStatusColorScheme } from '../chat/utils/agent-status-message'\nimport { DevCardRowContent } from '../shared/dev-section/dev-card-row'\nimport {\n  TicketDetailDrawer,\n  type TicketDetailDrawerProps,\n} 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\n    ? formatRelativeTime(ticket.hubspot_updated_at)\n    : '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\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 =\n    (!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 text-left p-[12px] md:p-[16px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-inset disabled:cursor-default\"\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 { Input, Label } from '../ui'\nimport { ContactForm } from '../contact'\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=\"h-full flex flex-col border border-ods-border rounded-2xl md:rounded-3xl p-6 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=\"h-10 w-72 bg-ods-border rounded animate-pulse mb-3 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-col flex-grow 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=\"h-[27px] w-20 bg-ods-border rounded animate-pulse mb-1\" />\n          <div className=\"h-12 w-full bg-ods-border rounded animate-pulse\" />\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-col flex-grow\">\n          <div className=\"h-[27px] w-32 bg-ods-border rounded animate-pulse mb-1\" />\n          <div className=\"h-24 w-full bg-ods-border rounded animate-pulse flex-grow\" />\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 bg-ods-border rounded animate-pulse shrink-0\" />\n            <div className=\"h-4 w-40 bg-ods-border rounded animate-pulse\" />\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=\"flex flex-col md:flex-row gap-4 md:gap-6 items-center justify-end w-full pt-2 mt-auto\">\n          <div className=\"h-4 w-72 bg-ods-border rounded animate-pulse\" />\n          <div className=\"h-12 w-32 bg-ods-border rounded animate-pulse\" />\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<span className=\"text-ods-accent\">*</span>\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-describedby={subjectError ? 'help-center-subject-error' : undefined}\n        disabled={supportSystemDown}\n        className=\"bg-ods-card border-ods-border text-ods-text-primary placeholder-ods-text-secondary px-3 h-12\"\n      />\n      {subjectError && (\n        <span\n          id=\"help-center-subject-error\"\n          className=\"text-ods-error text-h6 mt-1\"\n        >\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"]}