{
  "name": "chat-widget",
  "title": "ChatWidget",
  "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.",
  "type": "component",
  "registryDependencies": [
    "cn"
  ],
  "files": [
    {
      "path": "chat-widget.tsx",
      "content": "\"use client\";\n\nimport React, {\n  useState,\n  useRef,\n  useCallback,\n  type KeyboardEvent,\n} from \"react\";\nimport type { CimplifyClient } from \"../client\";\nimport { CimplifyError } from \"@cimplify/sdk\";\nimport type { ChatMessage, ChatUploadAttachment, ChatWidgetStarter } from \"../types/support\";\nimport { isOptimistic } from \"./chat-model\";\nimport {\n  ChatKitProgram,\n  ChatReplyCards,\n  MessageBubble,\n  Seam,\n  chatTimeline,\n  dayLabel,\n  deliveryState,\n  groupPosition,\n  proseOf,\n  sameDay,\n  type GenUIActionEvent,\n} from \"./chat-kit\";\nimport { useChat } from \"./hooks/use-chat\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;\n\n/** Mirrors the server allowlist: images plus inert document types. */\nconst DOCUMENT_MIME_TYPES = new Set([\n  \"application/pdf\",\n  \"text/plain\",\n  \"text/csv\",\n  \"application/msword\",\n  \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n  \"application/vnd.ms-excel\",\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n]);\n\nconst ATTACHMENT_ACCEPT = [\"image/*\", ...DOCUMENT_MIME_TYPES].join(\",\");\n\nfunction isAllowedAttachment(file: File): boolean {\n  return file.type.startsWith(\"image/\") || DOCUMENT_MIME_TYPES.has(file.type);\n}\nconst KEEP_DISMISSED_KEY = \"cimplify_keep_chat_dismissed\";\n/** How close to the bottom still counts as reading the live edge. */\nconst AT_BOTTOM_SLACK_PX = 48;\n\n/* ─── Public API ──────────────────────────────────────────── */\n\nexport interface ChatWidgetClassNames {\n  root?: string;\n  bubble?: string;\n  panel?: string;\n  header?: string;\n  messages?: string;\n  input?: string;\n  welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n  client?: CimplifyClient;\n  /** Business display name shown in the header */\n  businessName?: string;\n  /** Greeting text on the welcome screen */\n  greeting?: string;\n  /** Subtitle on the welcome screen */\n  subtitle?: string;\n  /** Conversation starters */\n  starters?: ChatWidgetStarter[];\n  /** Input placeholder */\n  placeholder?: string;\n  /** Position of the bubble */\n  position?: \"bottom-right\" | \"bottom-left\";\n  /** Start with the panel open */\n  defaultOpen?: boolean;\n  /** Poll interval in ms (default: 3000) */\n  pollInterval?: number;\n  /** Currency for structured product cards (e.g. \"GHS\"). */\n  currency?: string;\n  /**\n   * Receives taps on GenUI buttons and structured cards\n   * (`chat:view_product:{id}`, `chat:view_order:{id}`). Without it, cards\n   * render read-only and program buttons no-op.\n   */\n  onGenUIAction?: (event: GenUIActionEvent) => void;\n  className?: string;\n  classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n  client: clientProp,\n  businessName: businessNameProp,\n  greeting = \"Hi there!\",\n  subtitle = \"Ask us anything — we typically reply in a few seconds.\",\n  starters,\n  placeholder = \"Type a message\\u2026\",\n  position = \"bottom-right\",\n  defaultOpen = false,\n  pollInterval,\n  currency: currencyProp,\n  onGenUIAction,\n  className,\n  classNames,\n}: ChatWidgetProps) {\n  const context = useOptionalCimplifyClient();\n  const client = clientProp ?? context?.client;\n  // Provider-wrapped hosts get the real identity with zero config.\n  const businessName = businessNameProp ?? context?.business?.name ?? \"Support\";\n  const currency = currencyProp ?? context?.baseCurrency;\n\n  const [open, setOpen] = useState(defaultOpen);\n  const [readAt, setReadAt] = useState<string | null>(null);\n  const [attachError, setAttachError] = useState<CimplifyError | null>(null);\n  const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);\n\n  const {\n    messages,\n    isLoading,\n    isSending,\n    awaitingReply,\n    seenAt,\n    error,\n    isActive,\n    humanRequested,\n    claimedAs,\n    signOut,\n    send,\n    react,\n    requestHuman,\n    startConversation,\n    loadOlder,\n    mayHaveOlder,\n    openIfExisting,\n  } = useChat({ client, starters, pollInterval });\n  const showTyping = isSending || awaitingReply;\n\n  const attachFile = useCallback(\n    async (file: File) => {\n      if (!client) return;\n      setAttachError(null);\n      if (!isAllowedAttachment(file)) {\n        setAttachError(\n          new CimplifyError(\"invalid_attachment\", \"Only photos and documents can be attached.\"),\n        );\n        return;\n      }\n      if (file.size > MAX_ATTACHMENT_BYTES) {\n        setAttachError(new CimplifyError(\"attachment_too_large\", \"Files must be under 25MB.\"));\n        return;\n      }\n      const uploaded = await client.uploads.upload(file);\n      if (!uploaded.ok) {\n        setAttachError(uploaded.error);\n        return;\n      }\n      const attachment: ChatUploadAttachment = {\n        upload_id: uploaded.value.id,\n        url: uploaded.value.url,\n        mime_type: uploaded.value.content_type,\n        filename: uploaded.value.filename,\n        size: uploaded.value.size_bytes,\n      };\n      await send(\"\", { attachments: [attachment] });\n    },\n    [client, send],\n  );\n\n  const handleSend = useCallback(\n    (content: string) => {\n      setAttachError(null);\n      const target = replyTo;\n      setReplyTo(null);\n      return send(content, target ? { replyTo: target } : undefined);\n    },\n    [send, replyTo],\n  );\n\n  // Unread badge: incoming messages newer than the watermark snapshotted\n  // when the panel last closed.\n  const unread = open\n    ? 0\n    : messages.filter(\n        (m) => m.sender_type !== \"customer\" && (!readAt || m.created_at > readAt),\n      ).length;\n\n  useState(() => {\n    if (defaultOpen) void openIfExisting();\n  });\n\n  const handleOpen = useCallback(() => {\n    setOpen(true);\n    void openIfExisting();\n  }, [openIfExisting]);\n\n  const handleClose = useCallback(() => {\n    const newest = messages[messages.length - 1];\n    setReadAt(newest?.created_at ?? null);\n    setOpen(false);\n  }, [messages]);\n\n  const isLeft = position === \"bottom-left\";\n\n  return (\n    <div\n      className={cn(\n        \"fixed bottom-6 z-[9999]\",\n        isLeft ? \"left-6\" : \"right-6\",\n        className,\n        classNames?.root,\n      )}\n    >\n      {/* Bubble */}\n      {!open && (\n        <Bubble\n          unread={unread}\n          onClick={handleOpen}\n          className={classNames?.bubble}\n        />\n      )}\n\n      {/* Panel */}\n      {open && (\n        <Panel\n          client={client}\n          businessName={businessName}\n          greeting={greeting}\n          subtitle={subtitle}\n          starters={starters}\n          placeholder={placeholder}\n          messages={messages}\n          isLoading={isLoading}\n          isSending={isSending}\n          isTyping={showTyping}\n          seenAt={seenAt}\n          readAt={readAt}\n          error={error ?? attachError}\n          isActive={isActive}\n          isLeft={isLeft}\n          humanRequested={humanRequested}\n          mayHaveOlder={mayHaveOlder}\n          replyTo={replyTo}\n          claimedAs={claimedAs}\n          onSignOut={() => void signOut()}\n          onClose={handleClose}\n          onSend={handleSend}\n          onReact={react}\n          onReply={setReplyTo}\n          onCancelReply={() => setReplyTo(null)}\n          onRequestHuman={() => void requestHuman()}\n          onLoadOlder={() => void loadOlder()}\n          onAttach={attachFile}\n          onStartConversation={startConversation}\n          currency={currency}\n          onGenUIAction={onGenUIAction}\n          classNames={classNames}\n        />\n      )}\n    </div>\n  );\n}\n\n/* ─── Bubble ──────────────────────────────────────────────── */\n\nfunction Bubble({\n  unread,\n  onClick,\n  className,\n}: {\n  unread: number;\n  onClick: () => void;\n  className?: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      aria-label=\"Open chat\"\n      className={cn(\n        \"relative flex h-14 w-14 items-center justify-center rounded-full\",\n        \"bg-foreground text-background shadow-lg\",\n        \"transition-transform duration-300 ease-out\",\n        \"hover:scale-[1.08] active:scale-95\",\n        className,\n      )}\n    >\n      <ChatIcon className=\"h-6 w-6\" />\n      {unread > 0 && (\n        <span\n          className={cn(\n            \"absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center\",\n            \"rounded-full bg-destructive text-[10px] font-bold text-white\",\n            \"ring-2 ring-background\",\n          )}\n        >\n          {unread > 9 ? \"9+\" : unread}\n        </span>\n      )}\n    </button>\n  );\n}\n\n/* ─── Panel ───────────────────────────────────────────────── */\n\nfunction Panel({\n  client,\n  businessName,\n  greeting,\n  subtitle,\n  starters,\n  placeholder,\n  messages,\n  isLoading,\n  isSending,\n  isTyping,\n  seenAt,\n  readAt,\n  error,\n  isActive,\n  isLeft,\n  humanRequested,\n  mayHaveOlder,\n  replyTo,\n  claimedAs,\n  onSignOut,\n  onClose,\n  onSend,\n  onReact,\n  onReply,\n  onCancelReply,\n  onRequestHuman,\n  onLoadOlder,\n  onAttach,\n  onStartConversation,\n  currency,\n  onGenUIAction,\n  classNames,\n}: {\n  client: CimplifyClient | undefined;\n  businessName: string;\n  greeting: string;\n  subtitle: string;\n  starters?: ChatWidgetStarter[];\n  placeholder: string;\n  messages: ChatMessage[];\n  isLoading: boolean;\n  isSending: boolean;\n  isTyping: boolean;\n  seenAt: string | null;\n  readAt: string | null;\n  error: CimplifyError | null;\n  isActive: boolean;\n  isLeft: boolean;\n  humanRequested: boolean;\n  mayHaveOlder: boolean;\n  replyTo: ChatMessage | null;\n  claimedAs: string | null;\n  onSignOut: () => void;\n  onClose: () => void;\n  onSend: (content: string) => Promise<boolean>;\n  onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n  onReply: (message: ChatMessage) => void;\n  onCancelReply: () => void;\n  onRequestHuman: () => void;\n  onLoadOlder: () => void;\n  onAttach: (file: File) => Promise<void>;\n  onStartConversation: (text?: string) => Promise<void>;\n  currency?: string;\n  onGenUIAction?: (event: GenUIActionEvent) => void;\n  classNames?: ChatWidgetClassNames;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl\",\n        \"w-[400px] max-w-[calc(100vw-3rem)]\",\n        \"h-[min(600px,calc(100vh-6rem))]\",\n        \"animate-in fade-in slide-in-from-bottom-4 duration-300\",\n        isLeft ? \"origin-bottom-left\" : \"origin-bottom-right\",\n        classNames?.panel,\n      )}\n    >\n      {/* Header */}\n      <Header\n        businessName={businessName}\n        claimedAs={claimedAs}\n        onSignOut={onSignOut}\n        onClose={onClose}\n        className={classNames?.header}\n      />\n\n      {/* Body */}\n      {!isActive && !isLoading ? (\n        <Welcome\n          greeting={greeting}\n          subtitle={subtitle}\n          starters={starters}\n          onStarter={onStartConversation}\n          className={classNames?.welcome}\n        />\n      ) : (\n        <MessageList\n          messages={messages}\n          businessName={businessName}\n          isLoading={isLoading}\n          isSending={isTyping}\n          seenAt={seenAt}\n          readAt={readAt}\n          humanRequested={humanRequested}\n          mayHaveOlder={mayHaveOlder}\n          onReact={onReact}\n          onReply={onReply}\n          onRequestHuman={onRequestHuman}\n          onLoadOlder={onLoadOlder}\n          currency={currency}\n          onGenUIAction={onGenUIAction}\n          className={classNames?.messages}\n        />\n      )}\n\n      {isActive && !claimedAs && client && <KeepConversation client={client} />}\n\n      {humanRequested && isActive && (\n        <div className=\"flex items-center gap-2 border-t border-border bg-muted/50 px-4 py-2 text-[11px] text-muted-foreground\">\n          <span className=\"h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-amber-500\" />\n          Waiting for {businessName} — usually replies in minutes\n        </div>\n      )}\n\n      {/* Error */}\n      {error != null && (\n        <div className=\"border-t border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive\">\n          {error.userMessage || \"Something went wrong. Please try again.\"}\n        </div>\n      )}\n\n      {replyTo && (\n        <div className=\"flex items-start gap-2 border-t border-border bg-muted/50 px-4 py-2\">\n          <span className=\"w-[3px] shrink-0 self-stretch rounded-full bg-foreground/40\" />\n          <div className=\"min-w-0 flex-1\">\n            <div className=\"text-[11px] font-semibold text-foreground\">\n              Replying to {replyTo.sender_type === \"customer\" ? \"yourself\" : businessName}\n            </div>\n            <div className=\"truncate text-[11px] text-muted-foreground\">\n              {proseOf(replyTo.content) || \"Photo\"}\n            </div>\n          </div>\n          <button\n            type=\"button\"\n            aria-label=\"Cancel reply\"\n            onClick={onCancelReply}\n            className=\"flex-shrink-0 p-0.5 text-muted-foreground hover:text-foreground\"\n          >\n            <CloseIcon className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n      )}\n\n      {/* Always present: gating on an active conversation left a visitor who\n          wants to type their own question with only the starter buttons. */}\n      <ChatInput\n        placeholder={placeholder}\n        isSending={isSending}\n        onSend={isActive ? onSend : async (text) => { await onStartConversation(text); return true; }}\n        onAttach={onAttach}\n        className={classNames?.input}\n      />\n\n\n      {/* Footer */}\n      <div className=\"flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground\">\n        Powered by{\" \"}\n        <span className=\"font-semibold text-foreground\">Cimplify</span>\n      </div>\n    </div>\n  );\n}\n\n/* ─── Header ──────────────────────────────────────────────── */\n\nfunction Header({\n  businessName,\n  claimedAs,\n  onSignOut,\n  onClose,\n  className,\n}: {\n  businessName: string;\n  claimedAs: string | null;\n  onSignOut: () => void;\n  onClose: () => void;\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background\",\n        className,\n      )}\n    >\n      <div className=\"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-background/15\">\n        <ChatIcon className=\"h-5 w-5\" />\n      </div>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"truncate text-[15px] font-semibold leading-tight\">\n          {businessName}\n        </div>\n        {claimedAs ? (\n          <div className=\"flex items-center gap-1 truncate text-xs text-background/60\">\n            <span className=\"truncate\">Chatting as {claimedAs}</span>\n            <span aria-hidden=\"true\">·</span>\n            <button\n              type=\"button\"\n              onClick={onSignOut}\n              className=\"flex-shrink-0 underline underline-offset-2 hover:text-background\"\n            >\n              Not you?\n            </button>\n          </div>\n        ) : (\n          <div className=\"flex items-center gap-1.5 text-xs text-background/60\">\n            <span className=\"h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400\" />\n            Online\n          </div>\n        )}\n      </div>\n      <button\n        type=\"button\"\n        onClick={onClose}\n        aria-label=\"Close chat\"\n        className={cn(\n          \"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg\",\n          \"bg-background/10 text-background/70 transition-colors hover:bg-background/20 hover:text-background\",\n        )}\n      >\n        <CloseIcon className=\"h-[18px] w-[18px]\" />\n      </button>\n    </div>\n  );\n}\n\n/* ─── Welcome ─────────────────────────────────────────────── */\n\nfunction Welcome({\n  greeting,\n  subtitle,\n  starters,\n  onStarter,\n  className,\n}: {\n  greeting: string;\n  subtitle: string;\n  starters?: ChatWidgetStarter[];\n  onStarter: (text?: string) => Promise<void>;\n  className?: string;\n}) {\n  const defaultStarters: ChatWidgetStarter[] = [\n    { icon: \"\\ud83d\\udd0d\", text: \"Help me find a product\" },\n    { icon: \"\\ud83d\\uded2\", text: \"Check my cart\" },\n    { icon: \"\\ud83d\\udce6\", text: \"Track my order\" },\n  ];\n\n  const items = starters ?? defaultStarters;\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center\",\n        className,\n      )}\n    >\n      <div className=\"mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted\">\n        <ChatIcon className=\"h-7 w-7 text-muted-foreground\" />\n      </div>\n      <h3 className=\"mb-2 text-xl font-bold text-foreground\">{greeting}</h3>\n      <p className=\"mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground\">\n        {subtitle}\n      </p>\n      <div className=\"flex w-full max-w-[300px] flex-col gap-2\">\n        {items.map((s) => (\n          <button\n            key={s.text}\n            type=\"button\"\n            onClick={() => onStarter(s.text)}\n            className={cn(\n              \"flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3\",\n              \"text-left text-[13px] font-medium text-foreground\",\n              \"transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm\",\n            )}\n          >\n            {s.icon && (\n              <span className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]\">\n                {s.icon}\n              </span>\n            )}\n            <span>{s.text}</span>\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/* ─── Messages ────────────────────────────────────────────── */\n\nconst QUICK_REACTIONS = [\"👍\", \"❤️\"] as const;\n\nfunction LetterAvatar({ name }: { name: string }) {\n  return (\n    <span className=\"flex size-6 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background\">\n      {name.charAt(0).toUpperCase()}\n    </span>\n  );\n}\n\nfunction MessageList({\n  messages,\n  businessName,\n  isLoading,\n  isSending,\n  seenAt,\n  readAt,\n  humanRequested,\n  mayHaveOlder,\n  onReact,\n  onReply,\n  onRequestHuman,\n  onLoadOlder,\n  currency,\n  onGenUIAction,\n  className,\n}: {\n  messages: ChatMessage[];\n  businessName: string;\n  isLoading: boolean;\n  isSending: boolean;\n  seenAt: string | null;\n  readAt: string | null;\n  humanRequested: boolean;\n  mayHaveOlder: boolean;\n  onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n  onReply: (message: ChatMessage) => void;\n  onRequestHuman: () => void;\n  onLoadOlder: () => void;\n  currency?: string;\n  onGenUIAction?: (event: GenUIActionEvent) => void;\n  className?: string;\n}) {\n  const scrolledUpRef = useRef(false);\n  const timeline = chatTimeline(messages, { readAt, rendersPrograms: true });\n  const { bubbles, reactionsByTarget, firstUnreadId, unreadOnOpen, firstAgentMessageId } =\n    timeline;\n  const lastBubble = bubbles[bubbles.length - 1];\n  const offerHuman = !humanRequested && lastBubble?.sender_type === \"bot\" && !isSending;\n\n  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n    const node = event.currentTarget;\n    scrolledUpRef.current =\n      node.scrollHeight - node.scrollTop - node.clientHeight >= AT_BOTTOM_SLACK_PX;\n  };\n\n  return (\n    <div\n      onScroll={handleScroll}\n      style={{ background: \"var(--color-chat-canvas, var(--color-muted, #f3f5f3))\" }}\n      className={cn(\"flex flex-1 flex-col gap-0.5 overflow-y-auto px-3.5 py-3.5\", className)}\n    >\n      {isLoading && messages.length === 0 && (\n        <div className=\"flex flex-1 items-center justify-center\">\n          <TypingDots />\n        </div>\n      )}\n\n      {mayHaveOlder && (\n        <div className=\"flex justify-center pb-1\">\n          <button\n            type=\"button\"\n            onClick={onLoadOlder}\n            className=\"rounded-full border border-foreground/[0.06] bg-background px-3.5 py-[7px] text-xs font-semibold text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]\"\n          >\n            Show earlier messages\n          </button>\n        </div>\n      )}\n\n      {bubbles.map((msg, i) => {\n        const prev = bubbles[i - 1];\n        const newDay = !prev || !sameDay(prev.created_at, msg.created_at);\n        const group = groupPosition(timeline, i);\n        const startsRun = group === \"single\" || group === \"first\";\n        const delivery = deliveryState(msg, timeline, seenAt, isOptimistic);\n\n        return (\n          <div\n            key={msg.id}\n            className={cn(\"flex flex-col gap-1.5\", startsRun && i > 0 && \"mt-2.5\")}\n          >\n            {newDay && <Seam variant=\"chip\" label={dayLabel(msg.created_at)} />}\n            {msg.id === firstUnreadId && (\n              <Seam\n                variant=\"unread\"\n                label={`${unreadOnOpen} new message${unreadOnOpen === 1 ? \"\" : \"s\"}`}\n              />\n            )}\n            {msg.id === firstAgentMessageId && <Seam label=\"A person joined\" />}\n            {msg.sender_type === \"system\" ? (\n              <Seam label=\"Passing you to the team\" />\n            ) : (\n              <div className=\"group relative\">\n                {msg.sender_type !== \"customer\" && !isOptimistic(msg) && (\n                  <HoverRail message={msg} onReact={onReact} onReply={onReply} />\n                )}\n                <MessageBubble\n                  message={msg}\n                  reactions={reactionsByTarget.get(msg.id)}\n                  businessName={businessName}\n                  group={group}\n                  delivery={delivery}\n                  onReply={onReply}\n                  avatar={<LetterAvatar name={businessName} />}\n                  renderProgram={(source) => (\n                    <ChatKitProgram source={source} onAction={onGenUIAction} />\n                  )}\n                  renderCards={(target) => (\n                    <ChatReplyCards message={target} currency={currency} onAction={onGenUIAction} />\n                  )}\n                  onQuoteMiss={async () => {\n                    onLoadOlder();\n                    return true;\n                  }}\n                />\n              </div>\n            )}\n          </div>\n        );\n      })}\n\n      {isSending && (\n        <div className=\"mr-auto mt-1\">\n          <TypingDots />\n        </div>\n      )}\n\n      {offerHuman && (\n        <div className=\"mt-2\">\n          <button\n            type=\"button\"\n            onClick={onRequestHuman}\n            className=\"rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:border-foreground\"\n          >\n            Talk to a person\n          </button>\n        </div>\n      )}\n\n      <div\n        key={`${messages.length}:${isSending}`}\n        ref={(node) => {\n          // Keyed so the callback re-fires per new message/typing change —\n          // follows the conversation, but never yanks a reader out of history.\n          if (node && !scrolledUpRef.current) node.scrollIntoView({ behavior: \"smooth\" });\n        }}\n      />\n    </div>\n  );\n}\n\nfunction HoverRail({\n  message,\n  onReact,\n  onReply,\n}: {\n  message: ChatMessage;\n  onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n  onReply: (message: ChatMessage) => void;\n}) {\n  return (\n    <div className=\"absolute -top-3 right-0 z-10 hidden gap-0.5 rounded-full border border-border bg-background px-1 py-0.5 shadow-sm group-hover:flex\">\n      {QUICK_REACTIONS.map((emoji) => (\n        <button\n          key={emoji}\n          type=\"button\"\n          aria-label={`React ${emoji}`}\n          onClick={() => void onReact(message, emoji)}\n          className=\"flex h-6 w-6 items-center justify-center rounded-full text-[13px] hover:bg-muted\"\n        >\n          {emoji}\n        </button>\n      ))}\n      <button\n        type=\"button\"\n        aria-label=\"Reply\"\n        onClick={() => onReply(message)}\n        className=\"flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground\"\n      >\n        <ReplyIcon className=\"h-3.5 w-3.5\" />\n      </button>\n    </div>\n  );\n}\n\n/* ─── Keep this conversation ──────────────────────────────── */\n\nfunction readKeepDismissed(): boolean {\n  try {\n    return typeof window !== \"undefined\" && window.localStorage.getItem(KEEP_DISMISSED_KEY) === \"1\";\n  } catch {\n    return false;\n  }\n}\n\nfunction writeKeepDismissed(): void {\n  try {\n    window.localStorage.setItem(KEEP_DISMISSED_KEY, \"1\");\n  } catch {}\n}\n\nfunction KeepConversation({ client }: { client: CimplifyClient }) {\n  const [step, setStep] = useState<\"row\" | \"contact\" | \"code\">(\"row\");\n  const [dismissed, setDismissed] = useState(readKeepDismissed);\n  const [contact, setContact] = useState(\"\");\n  const [code, setCode] = useState(\"\");\n  const [challengeId, setChallengeId] = useState<string | null | undefined>(null);\n  const [busy, setBusy] = useState(false);\n  const [verifyError, setVerifyError] = useState<string | null>(null);\n\n  if (dismissed) return null;\n\n  const contactType = contact.includes(\"@\") ? \"email\" : \"phone\";\n\n  const requestCode = async () => {\n    const trimmed = contact.trim();\n    if (!trimmed || busy) return;\n    setBusy(true);\n    setVerifyError(null);\n    const result = await client.link.requestOtp({ contact: trimmed, contact_type: contactType });\n    setBusy(false);\n    if (result.ok) {\n      setChallengeId(result.value.challenge_id);\n      setCode(\"\");\n      setStep(\"code\");\n    } else {\n      setVerifyError(result.error.userMessage || \"Couldn't send a code — try again.\");\n    }\n  };\n\n  const verify = async (value: string) => {\n    if (value.length !== 6 || busy || challengeId === null) return;\n    setBusy(true);\n    setVerifyError(null);\n    const result = await client.link.verifyOtp({\n      ...(challengeId === undefined ? {} : { challenge_id: challengeId }),\n      contact: contact.trim(),\n      contact_type: contactType,\n      otp_code: value,\n    });\n    setBusy(false);\n    if (!result.ok) {\n      setCode(\"\");\n      setVerifyError(result.error.userMessage || \"Invalid or expired code.\");\n    }\n  };\n\n  return (\n    <div className=\"flex-shrink-0 border-t border-border bg-muted/50 px-4 py-2.5\">\n      {step === \"row\" && (\n        <div className=\"flex items-center gap-2.5\">\n          <button\n            type=\"button\"\n            onClick={() => setStep(\"contact\")}\n            className=\"flex min-w-0 flex-1 flex-col text-left\"\n          >\n            <span className=\"text-xs font-semibold text-foreground\">Keep this conversation</span>\n            <span className=\"truncate text-[11px] text-muted-foreground\">\n              Verify your number and it follows you — any device\n            </span>\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Dismiss\"\n            onClick={() => {\n              writeKeepDismissed();\n              setDismissed(true);\n            }}\n            className=\"flex-shrink-0 p-1 text-muted-foreground hover:text-foreground\"\n          >\n            <CloseIcon className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n      )}\n\n      {step === \"contact\" && (\n        <div className=\"flex items-center gap-2\">\n          <input\n            type=\"text\"\n            inputMode=\"email\"\n            value={contact}\n            onChange={(e) => setContact(e.target.value)}\n            onKeyDown={(e) => e.key === \"Enter\" && void requestCode()}\n            placeholder=\"Phone or email\"\n            autoFocus\n            className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground\"\n          />\n          <button\n            type=\"button\"\n            onClick={() => void requestCode()}\n            disabled={!contact.trim() || busy}\n            className=\"h-9 flex-shrink-0 rounded-lg bg-foreground px-3 text-xs font-semibold text-background disabled:opacity-50\"\n          >\n            {busy ? \"Sending…\" : \"Send code\"}\n          </button>\n        </div>\n      )}\n\n      {step === \"code\" && (\n        <div className=\"flex items-center gap-2\">\n          <input\n            type=\"text\"\n            inputMode=\"numeric\"\n            autoComplete=\"one-time-code\"\n            maxLength={6}\n            value={code}\n            onChange={(e) => {\n              const digits = e.target.value.replace(/\\D/g, \"\").slice(0, 6);\n              setCode(digits);\n              if (digits.length === 6) void verify(digits);\n            }}\n            placeholder=\"6-digit code\"\n            autoFocus\n            className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs tracking-[0.2em] text-foreground outline-none placeholder:tracking-normal placeholder:text-muted-foreground focus:border-foreground\"\n          />\n          <button\n            type=\"button\"\n            onClick={() => void requestCode()}\n            disabled={busy}\n            className=\"h-9 flex-shrink-0 rounded-lg px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground disabled:opacity-50\"\n          >\n            Resend\n          </button>\n        </div>\n      )}\n\n      {verifyError && <p className=\"mt-1.5 text-[11px] text-destructive\">{verifyError}</p>}\n    </div>\n  );\n}\n\n/* ─── Input ───────────────────────────────────────────────── */\n\nfunction ChatInput({\n  placeholder,\n  isSending,\n  onSend,\n  onAttach,\n  className,\n}: {\n  placeholder: string;\n  isSending: boolean;\n  onSend: (content: string) => Promise<boolean>;\n  onAttach: (file: File) => Promise<void>;\n  className?: string;\n}) {\n  const [value, setValue] = useState(\"\");\n  const [uploading, setUploading] = useState(false);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const fileRef = useRef<HTMLInputElement>(null);\n\n  const handleAttach = useCallback(\n    async (file: File) => {\n      setUploading(true);\n      try {\n        await onAttach(file);\n      } finally {\n        setUploading(false);\n      }\n    },\n    [onAttach],\n  );\n\n  const handleSend = useCallback(async () => {\n    const trimmed = value.trim();\n    if (!trimmed || isSending) return;\n    setValue(\"\");\n    if (textareaRef.current) {\n      textareaRef.current.style.height = \"auto\";\n    }\n    const sent = await onSend(trimmed);\n    // A failed send must not eat the message — put it back to retry.\n    if (!sent) setValue((current) => current || trimmed);\n  }, [value, isSending, onSend]);\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (e.key === \"Enter\" && !e.shiftKey) {\n        e.preventDefault();\n        handleSend();\n      }\n    },\n    [handleSend],\n  );\n\n  const handleInput = useCallback(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n  }, []);\n\n  const hasContent = value.trim().length > 0;\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3\",\n        className,\n      )}\n    >\n      <input\n        ref={fileRef}\n        type=\"file\"\n        accept={ATTACHMENT_ACCEPT}\n        className=\"hidden\"\n        aria-hidden=\"true\"\n        tabIndex={-1}\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          e.target.value = \"\";\n          if (file) void handleAttach(file);\n        }}\n      />\n      <button\n        type=\"button\"\n        aria-label=\"Attach a photo\"\n        disabled={uploading}\n        onClick={() => fileRef.current?.click()}\n        className={cn(\n          \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n          \"bg-muted text-muted-foreground transition-colors hover:text-foreground\",\n          uploading && \"animate-pulse\",\n        )}\n      >\n        <ImageIcon className=\"h-[18px] w-[18px]\" />\n      </button>\n      <div\n        className={cn(\n          \"flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1\",\n          \"transition-colors focus-within:border-foreground focus-within:bg-background\",\n        )}\n      >\n        <textarea\n          ref={textareaRef}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          onKeyDown={handleKeyDown}\n          onInput={handleInput}\n          placeholder={placeholder}\n          rows={1}\n          className={cn(\n            \"max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2\",\n            \"text-sm text-foreground outline-none placeholder:text-muted-foreground\",\n          )}\n        />\n      </div>\n\n      <button\n        type=\"button\"\n        onClick={handleSend}\n        disabled={!hasContent || isSending}\n        aria-label=\"Send message\"\n        className={cn(\n          \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n          \"transition-all duration-200\",\n          hasContent && !isSending\n            ? \"bg-foreground text-background hover:scale-105 active:scale-95\"\n            : \"bg-muted text-muted-foreground\",\n        )}\n      >\n        <SendIcon className=\"h-[18px] w-[18px]\" />\n      </button>\n    </div>\n  );\n}\n\n/* ─── Typing dots ─────────────────────────────────────────── */\n\nfunction TypingDots() {\n  return (\n    <div className=\"flex items-center gap-1 px-4 py-3\">\n      {[0, 1, 2].map((i) => (\n        <span\n          key={i}\n          className=\"h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40\"\n          style={{ animationDelay: `${i * 160}ms` }}\n        />\n      ))}\n    </div>\n  );\n}\n\n/* ─── Icons (inline SVG — no icon library) ────────────────── */\n\nfunction ChatIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d=\"M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z\" />\n    </svg>\n  );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d=\"M18 6L6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\" />\n      <polygon points=\"22 2 15 22 11 13 2 9 22 2\" />\n    </svg>\n  );\n}\n\nfunction ImageIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\" />\n      <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\" />\n      <polyline points=\"21 15 16 10 5 21\" />\n    </svg>\n  );\n}\n\nfunction ReplyIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <polyline points=\"9 17 4 12 9 7\" />\n      <path d=\"M20 18v-2a4 4 0 0 0-4-4H4\" />\n    </svg>\n  );\n}\n"
    }
  ]
}
