{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-chat-core",
  "title": "Chat core primitive",
  "description": "The chat model and hooks behind Eased's chat components: AI SDK UIMessage-compatible types, useStickToBottom, message grouping, the attachment model with useAttachments, composer key handling with slash/mention triggers, and a useChat adapter.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/chat-core/chat-core.ts",
      "content": "/**\n * P12 chat-core: the shared model and hooks behind Composer, Attachment Chip, Message,\n * Conversation and Chat Shell. Import from here.\n */\nexport * from \"./types\";\nexport * from \"./group-messages\";\nexport * from \"./attachments\";\nexport * from \"./composer-keys\";\nexport * from \"./use-stick-to-bottom\";\nexport * from \"./use-chat-adapter\";\nexport * from \"./use-autosize\";\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/chat-core.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chat-core/types.ts",
      "content": "/**\n * Chat message types, structurally compatible with Vercel AI SDK v5+ `UIMessage`.\n *\n * Eased never imports `ai`: these mirror its shapes, so `useChat().messages` passes straight\n * into Eased components and Eased messages pass back into `setMessages`. Only the parts Eased\n * renders are spelled out; every other part type still type-checks through `ChatUnknownPart`.\n *\n * Tool-call state (`input-streaming`, `output-available` …) is owned by `thinking-core`'s\n * `ToolState`; here it stays a plain string so this file has no cross-lane dependency.\n */\n\n/** `useChat().status`: `submitted` is waiting for the first chunk, `streaming` is receiving it. */\nexport type ChatStatus = \"submitted\" | \"streaming\" | \"ready\" | \"error\";\n\nexport type ChatRole = \"system\" | \"user\" | \"assistant\";\n\n/** Streaming state of a text or reasoning part. */\nexport type ChatPartState = \"streaming\" | \"done\";\n\nexport type ChatTextPart = { type: \"text\"; text: string; state?: ChatPartState };\nexport type ChatReasoningPart = { type: \"reasoning\"; text: string; state?: ChatPartState };\n\n/** An attached file. `url` is a hosted URL or a `data:` URL, as AI SDK expects. */\nexport type ChatFilePart = { type: \"file\"; mediaType: string; filename?: string; url: string };\n\nexport type ChatSourceUrlPart = { type: \"source-url\"; sourceId: string; url: string; title?: string };\nexport type ChatStepStartPart = { type: \"step-start\" };\n\n/** A tool call: `type` is `tool-<name>`. `state` mirrors thinking-core's `ToolState`. */\nexport type ChatToolPart = {\n  type: `tool-${string}`;\n  toolCallId: string;\n  state: string;\n  input?: unknown;\n  output?: unknown;\n  errorText?: string;\n};\n\n/** Anything else AI SDK may send (`data-*`, `dynamic-tool`, `source-document` …). */\nexport type ChatUnknownPart = { type: string; [key: string]: unknown };\n\nexport type ChatMessagePart =\n  | ChatTextPart\n  | ChatReasoningPart\n  | ChatFilePart\n  | ChatSourceUrlPart\n  | ChatStepStartPart\n  | ChatToolPart\n  | ChatUnknownPart;\n\n/**\n * One message. Same shape as AI SDK's `UIMessage<METADATA>`: `id`, `role`, `parts`, and an\n * optional `metadata` you define (Eased reads `authorId` and `createdAt` from it when present).\n */\nexport type ChatMessage<Metadata = unknown> = {\n  id: string;\n  role: ChatRole;\n  parts: ChatMessagePart[];\n  metadata?: Metadata;\n};\n\nexport const isTextPart = (p: ChatMessagePart): p is ChatTextPart => p.type === \"text\";\nexport const isFilePart = (p: ChatMessagePart): p is ChatFilePart => p.type === \"file\";\nexport const isReasoningPart = (p: ChatMessagePart): p is ChatReasoningPart => p.type === \"reasoning\";\nexport const isToolPart = (p: ChatMessagePart): p is ChatToolPart =>\n  p.type.startsWith(\"tool-\") && typeof (p as ChatToolPart).toolCallId === \"string\";\n\n/** The message's text parts joined, e.g. for Copy. */\nexport function messageText(message: Pick<ChatMessage, \"parts\">) {\n  return message.parts.filter(isTextPart).map((p) => p.text).join(\"\");\n}\n\n/** True while a reply is on its way: submitted or streaming. */\nexport const isBusy = (status: ChatStatus | undefined) => status === \"submitted\" || status === \"streaming\";\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/types.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chat-core/group-messages.ts",
      "content": "import type { ChatMessage } from \"./types\";\n\n/** Where a message sits in a run of consecutive messages from one author. */\nexport type GroupPosition = \"single\" | \"first\" | \"middle\" | \"last\";\n\nexport type GroupOptions<M extends ChatMessage = ChatMessage> = {\n  /**\n   * Who wrote the message. Default: `metadata.authorId` when present, else the role, so a\n   * group chat splits by person and an AI chat by user/assistant.\n   */\n  getAuthor?: (message: M) => string;\n  /** When the message was sent, ms. Default: `metadata.createdAt` (number, string or Date). */\n  getTime?: (message: M) => number | undefined;\n  /** A pause longer than this starts a new group even for the same author, ms. Default 5 minutes. */\n  gap?: number;\n};\n\nexport type MessageGroup<M extends ChatMessage = ChatMessage> = {\n  /** Stable key: the first message's id. */\n  key: string;\n  author: string;\n  role: M[\"role\"];\n  messages: { message: M; position: GroupPosition }[];\n};\n\ntype Meta = { authorId?: unknown; createdAt?: unknown } | undefined;\n\nconst defaultAuthor = (m: ChatMessage) => {\n  const id = (m.metadata as Meta)?.authorId;\n  return typeof id === \"string\" ? id : m.role;\n};\n\nconst defaultTime = (m: ChatMessage) => {\n  const t = (m.metadata as Meta)?.createdAt;\n  if (typeof t === \"number\") return t;\n  if (typeof t === \"string\" || t instanceof Date) {\n    const ms = new Date(t).getTime();\n    return Number.isNaN(ms) ? undefined : ms;\n  }\n  return undefined;\n};\n\nfunction positionOf(index: number, length: number): GroupPosition {\n  if (length === 1) return \"single\";\n  if (index === 0) return \"first\";\n  return index === length - 1 ? \"last\" : \"middle\";\n}\n\n/**\n * Splits a flat message list into runs of consecutive messages from one author. Use the\n * groups to render one avatar and name per run, and each message's `position` to merge\n * bubble corners. Pure and cheap: call it in render.\n */\nexport function groupMessages<M extends ChatMessage>(messages: readonly M[], options: GroupOptions<M> = {}): MessageGroup<M>[] {\n  const { getAuthor = defaultAuthor as (m: M) => string, getTime = defaultTime as (m: M) => number | undefined, gap = 5 * 60_000 } = options;\n  const groups: { key: string; author: string; role: M[\"role\"]; items: M[] }[] = [];\n  let lastTime: number | undefined;\n\n  for (const message of messages) {\n    const author = getAuthor(message);\n    const time = getTime(message);\n    const current = groups[groups.length - 1];\n    const paused = time !== undefined && lastTime !== undefined && time - lastTime > gap;\n    if (current && current.author === author && current.role === message.role && !paused) current.items.push(message);\n    else groups.push({ key: message.id, author, role: message.role, items: [message] });\n    if (time !== undefined) lastTime = time;\n  }\n\n  return groups.map(({ key, author, role, items }) => ({\n    key,\n    author,\n    role,\n    messages: items.map((message, i) => ({ message, position: positionOf(i, items.length) })),\n  }));\n}\n\n/** Each message's group position by id, for lists you render flat. */\nexport function groupPositions<M extends ChatMessage>(messages: readonly M[], options?: GroupOptions<M>) {\n  const map = new Map<string, GroupPosition>();\n  for (const group of groupMessages(messages, options)) for (const { message, position } of group.messages) map.set(message.id, position);\n  return map;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/group-messages.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chat-core/attachments.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ChatFilePart } from \"./types\";\n\n/* ---------- The model ---------------------------------------------------------- */\n\nexport type AttachmentKind = \"image\" | \"file\";\nexport type AttachmentStatus = \"uploading\" | \"done\" | \"error\";\n\n/** One attachment in a composer or a sent message. Plain data: store it anywhere. */\nexport type Attachment = {\n  id: string;\n  name: string;\n  /** `image` shows a thumbnail; everything else is a file chip. */\n  kind: AttachmentKind;\n  /** MIME type, e.g. `image/png`, `application/pdf`. */\n  mediaType: string;\n  /** Bytes. */\n  size?: number;\n  /** Where the file lives once uploaded (or a `data:` URL). Sent as the `file` part's `url`. */\n  url?: string;\n  /** A local preview for images while they upload (an object URL). */\n  previewUrl?: string;\n  /** Default `done`. */\n  status?: AttachmentStatus;\n  /** Upload progress 0–1 while `uploading`; leave it out when unknown (the ring spins). */\n  progress?: number;\n  /** Shown under the name when `status` is `error`, e.g. \"Over 20 MB\". */\n  error?: string;\n  /** The original file, when it came from the user's device. */\n  file?: File;\n};\n\nexport const kindOf = (mediaType: string): AttachmentKind => (mediaType.startsWith(\"image/\") ? \"image\" : \"file\");\n\nlet counter = 0;\nconst newId = () => (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto ? crypto.randomUUID() : `att-${Date.now()}-${++counter}`);\n\n/** An attachment for a picked, dropped or pasted file. Images get an object-URL preview: call `releaseAttachment` when done. */\nexport function attachmentFromFile(file: File, status: AttachmentStatus = \"uploading\"): Attachment {\n  const mediaType = file.type || \"application/octet-stream\";\n  const kind = kindOf(mediaType);\n  return {\n    id: newId(),\n    name: file.name || (kind === \"image\" ? \"Pasted image\" : \"File\"),\n    kind,\n    mediaType,\n    size: file.size,\n    previewUrl: kind === \"image\" ? URL.createObjectURL(file) : undefined,\n    status,\n    file,\n  };\n}\n\n/** Frees an attachment's object-URL preview. */\nexport function releaseAttachment(a: Attachment) {\n  if (a.previewUrl?.startsWith(\"blob:\")) URL.revokeObjectURL(a.previewUrl);\n}\n\n/** `1.2 MB`, `840 KB`, with a no-break space. */\nexport function formatBytes(bytes: number, locale?: Intl.LocalesArgument) {\n  if (bytes < 1000) return `${new Intl.NumberFormat(locale).format(bytes)}\\u00a0${bytes === 1 ? \"byte\" : \"bytes\"}`;\n  const units = [\"byte\", \"kilobyte\", \"megabyte\", \"gigabyte\"] as const;\n  let i = 0;\n  let n = bytes;\n  while (n >= 1000 && i < units.length - 1) {\n    n /= 1000;\n    i++;\n  }\n  return new Intl.NumberFormat(locale, { style: \"unit\", unit: units[i], unitDisplay: \"short\", maximumFractionDigits: n < 10 && i > 0 ? 1 : 0 })\n    .format(n)\n    .replace(\" \", \" \");\n}\n\n/** The short type label for a file chip: `PDF`, `CSV`, `ZIP`. */\nexport function fileTypeLabel(a: Pick<Attachment, \"name\" | \"mediaType\">) {\n  const ext = a.name.includes(\".\") ? a.name.split(\".\").pop() : undefined;\n  return (ext || a.mediaType.split(\"/\").pop() || \"file\").slice(0, 4).toUpperCase();\n}\n\n/** An AI SDK `file` part for a finished attachment (`sendMessage({ text, files })`). Returns null until it has a URL. */\nexport function toFilePart(a: Attachment): ChatFilePart | null {\n  const url = a.url ?? (a.status === \"done\" || a.status === undefined ? a.previewUrl : undefined);\n  return url ? { type: \"file\", mediaType: a.mediaType, filename: a.name, url } : null;\n}\n\n/** The reverse: an attachment to render a sent `file` part. */\nexport function fromFilePart(part: ChatFilePart, id = part.url): Attachment {\n  return { id, name: part.filename ?? \"Attachment\", kind: kindOf(part.mediaType), mediaType: part.mediaType, url: part.url, status: \"done\" };\n}\n\n/* ---------- useAttachments ---------------------------------------------------- */\n\n/** Uploads one file. Report progress 0–1; resolve with where it now lives. Respect `signal`. */\nexport type UploadFn = (file: File, ctx: { onProgress: (fraction: number) => void; signal: AbortSignal }) => Promise<{ url: string }>;\n\nexport type UseAttachmentsOptions = {\n  /** Upload each file as it is added. Without it, files are kept locally (`done`, previewed by object URL). */\n  upload?: UploadFn;\n  /** Same syntax as `<input accept>`, e.g. `\"image/*,.pdf\"`. Files that do not match are rejected. */\n  accept?: string;\n  /** Bytes; larger files are added with an error so the user sees why. */\n  maxSize?: number;\n  /** Extra files past this are dropped and reported through `onReject`. */\n  maxFiles?: number;\n  /** Called with files that were refused, and why. */\n  onReject?: (rejected: { file: File; reason: \"type\" | \"count\" }[]) => void;\n  /** Controlled list. Pair with `onChange`. */\n  value?: Attachment[];\n  defaultValue?: Attachment[];\n  onChange?: (attachments: Attachment[]) => void;\n};\n\nexport function matchesAccept(file: File, accept?: string) {\n  if (!accept) return true;\n  const name = file.name.toLowerCase();\n  const type = file.type.toLowerCase();\n  return accept.split(\",\").some((raw) => {\n    const rule = raw.trim().toLowerCase();\n    if (!rule) return false;\n    if (rule.startsWith(\".\")) return name.endsWith(rule);\n    if (rule.endsWith(\"/*\")) return type.startsWith(rule.slice(0, -1));\n    return type === rule;\n  });\n}\n\n/**\n * The attachment state machine for a composer: add (pick, drop, paste), upload with progress,\n * cancel on remove, retry on error, and clear after sending. Object-URL previews are freed\n * when an attachment leaves the list and on unmount.\n */\nexport function useAttachments({ upload, accept, maxSize, maxFiles, onReject, value, defaultValue, onChange }: UseAttachmentsOptions = {}) {\n  const [inner, setInner] = useState<Attachment[]>(defaultValue ?? []);\n  const attachments = value ?? inner;\n  const list = useRef(attachments);\n  const controllers = useRef(new Map<string, AbortController>());\n  const latest = useRef({ upload, onChange, controlled: value !== undefined });\n  useLayoutEffect(() => {\n    list.current = attachments;\n    latest.current = { upload, onChange, controlled: value !== undefined };\n  });\n\n  const commit = useCallback((next: Attachment[]) => {\n    for (const a of list.current) if (!next.some((n) => n.id === a.id)) releaseAttachment(a);\n    list.current = next;\n    if (!latest.current.controlled) setInner(next);\n    latest.current.onChange?.(next);\n  }, []);\n\n  const patch = useCallback(\n    (id: string, changes: Partial<Attachment>) => {\n      if (!list.current.some((a) => a.id === id)) return;\n      commit(list.current.map((a) => (a.id === id ? { ...a, ...changes } : a)));\n    },\n    [commit],\n  );\n\n  const start = useCallback(\n    (a: Attachment) => {\n      const fn = latest.current.upload;\n      if (!fn || !a.file) return;\n      const controller = new AbortController();\n      controllers.current.set(a.id, controller);\n      let last = 0;\n      fn(a.file, {\n        signal: controller.signal,\n        onProgress: (fraction) => {\n          // Throttle to visible steps; the ring springs between them.\n          if (controller.signal.aborted || (fraction - last < 0.01 && fraction < 1)) return;\n          last = fraction;\n          patch(a.id, { progress: Math.min(1, Math.max(0, fraction)) });\n        },\n      }).then(\n        ({ url }) => {\n          controllers.current.delete(a.id);\n          if (!controller.signal.aborted) patch(a.id, { status: \"done\", progress: 1, url, error: undefined });\n        },\n        (err: unknown) => {\n          controllers.current.delete(a.id);\n          if (!controller.signal.aborted) patch(a.id, { status: \"error\", error: err instanceof Error ? err.message : \"Upload failed\" });\n        },\n      );\n    },\n    [patch],\n  );\n\n  const add = useCallback(\n    (files: Iterable<File> | ArrayLike<File>) => {\n      const incoming = Array.from(files);\n      const rejected: { file: File; reason: \"type\" | \"count\" }[] = [];\n      const room = maxFiles === undefined ? Infinity : Math.max(0, maxFiles - list.current.length);\n      const added: Attachment[] = [];\n      for (const file of incoming) {\n        if (!matchesAccept(file, accept)) rejected.push({ file, reason: \"type\" });\n        else if (added.length >= room) rejected.push({ file, reason: \"count\" });\n        else {\n          const tooBig = maxSize !== undefined && file.size > maxSize;\n          const a = attachmentFromFile(file, tooBig ? \"error\" : latest.current.upload ? \"uploading\" : \"done\");\n          if (tooBig) a.error = `Over ${formatBytes(maxSize!)}`;\n          added.push(a);\n        }\n      }\n      if (rejected.length) onReject?.(rejected);\n      if (!added.length) return [];\n      commit([...list.current, ...added]);\n      for (const a of added) if (a.status === \"uploading\") start(a);\n      return added;\n    },\n    [accept, maxFiles, maxSize, onReject, commit, start],\n  );\n\n  const remove = useCallback(\n    (id: string) => {\n      controllers.current.get(id)?.abort();\n      controllers.current.delete(id);\n      commit(list.current.filter((a) => a.id !== id));\n    },\n    [commit],\n  );\n\n  const retry = useCallback(\n    (id: string) => {\n      const a = list.current.find((x) => x.id === id);\n      if (!a?.file || !latest.current.upload) return;\n      if (maxSize !== undefined && a.file.size > maxSize) return;\n      const next = { ...a, status: \"uploading\" as const, progress: undefined, error: undefined };\n      commit(list.current.map((x) => (x.id === id ? next : x)));\n      start(next);\n    },\n    [commit, start, maxSize],\n  );\n\n  /** Empties the list after a send. Previews are kept alive for `keepPreviews` (the sent message may still show them). */\n  const clear = useCallback(\n    ({ keepPreviews = true }: { keepPreviews?: boolean } = {}) => {\n      for (const c of controllers.current.values()) c.abort();\n      controllers.current.clear();\n      if (keepPreviews) list.current = [];\n      commit([]);\n    },\n    [commit],\n  );\n\n  useEffect(() => {\n    const ctrl = controllers.current;\n    return () => {\n      for (const c of ctrl.values()) c.abort();\n      for (const a of list.current) releaseAttachment(a);\n    };\n  }, []);\n\n  const uploading = attachments.some((a) => a.status === \"uploading\");\n  const failed = attachments.some((a) => a.status === \"error\");\n\n  return {\n    attachments,\n    add,\n    remove,\n    retry,\n    clear,\n    update: patch,\n    /** True while any file is still uploading: hold Send until it is false. */\n    uploading,\n    /** True when any file failed: Send skips those files. */\n    failed,\n    /** The finished attachments as AI SDK `file` parts. */\n    fileParts: attachments.map(toFilePart).filter((p): p is ChatFilePart => p !== null),\n  };\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/attachments.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chat-core/composer-keys.ts",
      "content": "\"use client\";\n\nimport { useCallback, useRef, type KeyboardEvent } from \"react\";\n\n/** A `/command` or `@mention` being typed: what the Pro Slash and Mention menus open on. */\nexport type ComposerTrigger = {\n  /** The trigger character, e.g. `/` or `@`. */\n  char: string;\n  /** What follows it so far, e.g. `\"summ\"` for `/summ`. */\n  query: string;\n  /** Index of the trigger character in the value. */\n  start: number;\n  /** Index just past the query (the caret). */\n  end: number;\n};\n\n/**\n * The trigger under the caret, or null. `/` counts only at the start of a line (so URLs and\n * fractions never open the slash menu); other characters count after whitespace or at the\n * start. The query stops at whitespace.\n */\nexport function findTrigger(value: string, caret: number, chars: readonly string[] = [\"/\", \"@\"]): ComposerTrigger | null {\n  let i = caret - 1;\n  while (i >= 0 && !/\\s/.test(value[i])) {\n    if (chars.includes(value[i])) {\n      const before = i === 0 ? \"\\n\" : value[i - 1];\n      const ok = value[i] === \"/\" ? before === \"\\n\" : /\\s/.test(before);\n      return ok ? { char: value[i], query: value.slice(i + 1, caret), start: i, end: caret } : null;\n    }\n    i--;\n  }\n  return null;\n}\n\n/** Replaces a trigger with the picked item, e.g. `@ma` → `@Maya Chen `. Returns the new value and caret. */\nexport function replaceTrigger(value: string, trigger: ComposerTrigger, insert: string) {\n  const next = value.slice(0, trigger.start) + insert + value.slice(trigger.end);\n  return { value: next, caret: trigger.start + insert.length };\n}\n\nexport type ComposerKeyOptions = {\n  /** Called on the submit chord when `canSubmit` is true. */\n  onSubmit: () => void;\n  /** False when there is nothing to send or a reply is streaming. Default true. */\n  canSubmit?: boolean;\n  /** `enter` (default): Enter sends, Shift+Enter breaks the line. `mod-enter`: ⌘/Ctrl+Enter sends. */\n  submitOn?: \"enter\" | \"mod-enter\";\n  /** ⌘↑ / Ctrl+↑ anywhere, or ↑ in an empty composer: edit your last message. */\n  onEditLast?: () => void;\n  /** Escape: e.g. stop the stream, or blur. Return true if handled. */\n  onEscape?: () => boolean | void;\n  /**\n   * Runs first on every keydown. Return true to claim the key: an open Slash or Mention menu\n   * uses this for ↑ ↓ Enter Tab Escape, and the composer then ignores it.\n   */\n  intercept?: (event: KeyboardEvent<HTMLTextAreaElement>) => boolean;\n  /** Characters that open a trigger menu. Default `[\"/\", \"@\"]`. */\n  triggerChars?: readonly string[];\n  /** The trigger under the caret after each keystroke or caret move, or null. Wire the Pro menus here. */\n  onTrigger?: (trigger: ComposerTrigger | null) => void;\n};\n\n/**\n * Keyboard handling for a chat composer: the submit chord (IME-safe: Enter that confirms a\n * Japanese or Chinese composition never sends), edit-last, Escape, and trigger detection for\n * slash commands and mentions. Spread the result onto the `<textarea>`.\n */\nexport function useComposerKeys({\n  onSubmit,\n  canSubmit = true,\n  submitOn = \"enter\",\n  onEditLast,\n  onEscape,\n  intercept,\n  triggerChars = [\"/\", \"@\"],\n  onTrigger,\n}: ComposerKeyOptions) {\n  const lastTrigger = useRef<string | null>(null);\n\n  const report = useCallback(\n    (el: HTMLTextAreaElement) => {\n      if (!onTrigger) return;\n      const t = el.selectionStart === el.selectionEnd ? findTrigger(el.value, el.selectionStart, triggerChars) : null;\n      const sig = t ? `${t.char}${t.start}:${t.query}` : null;\n      if (sig === lastTrigger.current) return;\n      lastTrigger.current = sig;\n      onTrigger(t);\n    },\n    [onTrigger, triggerChars],\n  );\n\n  const onKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (intercept?.(e)) return;\n      // 229 covers Safari, which fires keydown for the composition-confirming Enter without isComposing.\n      if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n      const mod = e.metaKey || e.ctrlKey;\n\n      if (e.key === \"Enter\") {\n        const chord = submitOn === \"enter\" ? !e.shiftKey && !e.altKey : mod;\n        if (!chord) return;\n        e.preventDefault();\n        if (canSubmit) onSubmit();\n        return;\n      }\n      if (e.key === \"ArrowUp\" && onEditLast && (mod || e.currentTarget.value === \"\")) {\n        e.preventDefault();\n        onEditLast();\n        return;\n      }\n      if (e.key === \"Escape\" && onEscape?.()) e.preventDefault();\n    },\n    [intercept, submitOn, canSubmit, onSubmit, onEditLast, onEscape],\n  );\n\n  const onSelect = useCallback((e: { currentTarget: HTMLTextAreaElement }) => report(e.currentTarget), [report]);\n\n  return { onKeyDown, onSelect, onInput: onSelect };\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/composer-keys.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chat-core/use-stick-to-bottom.ts",
      "content": "\"use client\";\n\nimport { animate, motionValue, type AnimationPlaybackControls } from \"motion/react\";\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport { smooth, useSafeReducedMotion } from \"../../lib/motion\";\n\nexport type UseStickToBottomOptions = {\n  /** Within this many px of the bottom counts as \"at the bottom\" and re-locks. Default 48. */\n  threshold?: number;\n  /** Follow new content on a smooth spring (default) or jump. Reduced motion always jumps. */\n  animated?: boolean;\n  /** Start at the bottom on mount. Default true. */\n  initial?: boolean;\n};\n\nexport type StickToBottom = {\n  /** Put on the scrolling element (`overflow-y: auto`). */\n  scrollRef: (el: HTMLElement | null) => void;\n  /** Put on the element inside it that grows as messages stream. */\n  contentRef: (el: HTMLElement | null) => void;\n  /** True while following. False once the user scrolls up: show a \"Jump to latest\" pill. */\n  isAtBottom: boolean;\n  /** Scroll to the bottom and follow again (the pill's onClick). */\n  scrollToBottom: (opts?: { animated?: boolean }) => void;\n  /** Stop following without scrolling, e.g. when the user selects text. */\n  stopFollowing: () => void;\n  /**\n   * Call just before prepending older messages: the next growth keeps what is on screen\n   * still instead of pushing it down.\n   */\n  preserveScroll: () => void;\n};\n\n/**\n * Keeps a chat scrolled to the newest message while it streams, and lets go the moment the\n * user scrolls up (wheel, touch, keyboard or scrollbar). Following rides one spring that is\n * retargeted as each chunk lands, so bursty tokens glide instead of stepping; it never fights\n * the user, because any scroll that is not the spring's own stops it. Scrolling back within\n * `threshold` of the bottom locks on again.\n */\nexport function useStickToBottom({ threshold = 48, animated = true, initial = true }: UseStickToBottomOptions = {}): StickToBottom {\n  const reduced = useSafeReducedMotion();\n  const [isAtBottom, setIsAtBottom] = useState(initial);\n  const scroller = useRef<HTMLElement | null>(null);\n  const content = useRef<HTMLElement | null>(null);\n  const locked = useRef(initial);\n  const top = useRef(motionValue(0));\n  const anim = useRef<AnimationPlaybackControls | null>(null);\n  /** Where the spring last put scrollTop; scroll events that match it are ours. */\n  const expected = useRef<number | null>(null);\n  const anchor = useRef<number | null>(null);\n  const opts = useRef({ threshold, animated, reduced });\n  useLayoutEffect(() => {\n    opts.current = { threshold, animated, reduced };\n  });\n\n  const setBottom = useCallback((v: boolean) => {\n    locked.current = v;\n    setIsAtBottom((prev) => (prev === v ? prev : v));\n  }, []);\n\n  const stop = useCallback(() => {\n    anim.current?.stop();\n    anim.current = null;\n    expected.current = null;\n  }, []);\n\n  const follow = useCallback(\n    (animate_ = opts.current.animated) => {\n      const el = scroller.current;\n      if (!el) return;\n      const target = el.scrollHeight - el.clientHeight;\n      if (target - el.scrollTop < 1) return;\n      if (!animate_ || opts.current.reduced) {\n        stop();\n        expected.current = target;\n        el.scrollTop = target;\n        return;\n      }\n      // Retargeting a running spring keeps its velocity, so a stream of chunks reads as one glide.\n      if (!anim.current) top.current.jump(el.scrollTop);\n      anim.current = animate(top.current, target, { ...smooth, onComplete: () => (anim.current = null) });\n    },\n    [stop],\n  );\n\n  // The spring writes scrollTop; remember what it wrote so the scroll listener can tell it from the user.\n  useLayoutEffect(\n    () =>\n      top.current.on(\"change\", (v) => {\n        const el = scroller.current;\n        if (!el) return;\n        expected.current = v;\n        el.scrollTop = v;\n      }),\n    [],\n  );\n\n  const onResize = useCallback(() => {\n    const el = scroller.current;\n    if (!el) return;\n    if (anchor.current !== null) {\n      expected.current = el.scrollHeight - anchor.current;\n      el.scrollTop = expected.current;\n      anchor.current = null;\n      return;\n    }\n    if (locked.current) follow();\n  }, [follow]);\n\n  const onScroll = useCallback(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const max = el.scrollHeight - el.clientHeight;\n    if (expected.current !== null && Math.abs(el.scrollTop - Math.min(expected.current, max)) < 2) return;\n    // Not the spring: the user (or the browser) moved it.\n    stop();\n    setBottom(max - el.scrollTop <= opts.current.threshold);\n  }, [setBottom, stop]);\n\n  const onWheel = useCallback(\n    (e: WheelEvent) => {\n      if (e.deltaY < 0 && anim.current) {\n        stop();\n        setBottom(false);\n      }\n    },\n    [setBottom, stop],\n  );\n  const onTouch = useCallback(() => {\n    if (anim.current) stop();\n  }, [stop]);\n\n  const observers = useRef<{ ro: ResizeObserver | null }>({ ro: null });\n  const getObserver = () => (observers.current.ro ??= new ResizeObserver(() => onResize()));\n\n  const scrollRef = useCallback(\n    (el: HTMLElement | null) => {\n      const prev = scroller.current;\n      if (prev) {\n        prev.removeEventListener(\"scroll\", onScroll);\n        prev.removeEventListener(\"wheel\", onWheel);\n        prev.removeEventListener(\"touchmove\", onTouch);\n        getObserver().unobserve(prev);\n      }\n      scroller.current = el;\n      if (!el) return;\n      el.addEventListener(\"scroll\", onScroll, { passive: true });\n      el.addEventListener(\"wheel\", onWheel, { passive: true });\n      el.addEventListener(\"touchmove\", onTouch, { passive: true });\n      // The viewport shrinking (a composer growing below it) must keep the bottom in view too.\n      getObserver().observe(el);\n      if (locked.current) {\n        expected.current = el.scrollHeight;\n        el.scrollTop = el.scrollHeight;\n      }\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [onScroll, onWheel, onTouch],\n  );\n\n  const contentRef = useCallback(\n    (el: HTMLElement | null) => {\n      if (content.current) getObserver().unobserve(content.current);\n      content.current = el;\n      if (el) getObserver().observe(el);\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [],\n  );\n\n  const scrollToBottom = useCallback(\n    ({ animated: a }: { animated?: boolean } = {}) => {\n      setBottom(true);\n      follow(a ?? opts.current.animated);\n    },\n    [follow, setBottom],\n  );\n\n  const stopFollowing = useCallback(() => {\n    stop();\n    setBottom(false);\n  }, [setBottom, stop]);\n\n  const preserveScroll = useCallback(() => {\n    const el = scroller.current;\n    if (el) anchor.current = el.scrollHeight - el.scrollTop;\n  }, []);\n\n  useLayoutEffect(\n    () => () => {\n      anim.current?.stop();\n      observers.current.ro?.disconnect();\n    },\n    [],\n  );\n\n  return { scrollRef, contentRef, isAtBottom, scrollToBottom, stopFollowing, preserveScroll };\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/use-stick-to-bottom.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chat-core/use-chat-adapter.ts",
      "content": "import type { Attachment } from \"./attachments\";\nimport { toFilePart } from \"./attachments\";\nimport type { ChatFilePart, ChatMessage, ChatStatus } from \"./types\";\n\n/** What the Composer hands to `onSubmit`. */\nexport type ComposerSubmission = {\n  text: string;\n  /** Every attachment in the row, finished or not; `files` is the sendable subset. */\n  attachments: Attachment[];\n  /** The finished attachments as AI SDK `file` parts. */\n  files: ChatFilePart[];\n};\n\n/**\n * The slice of AI SDK v5+ `useChat()` the adapter reads. Structural, so Eased has no `ai`\n * dependency: pass the hook's return value as is.\n */\nexport type UseChatLike<M extends ChatMessage = ChatMessage> = {\n  messages: M[];\n  status: ChatStatus;\n  sendMessage: (message: { text: string; files?: ChatFilePart[] }) => unknown;\n  stop: () => unknown;\n  regenerate?: (options?: { messageId?: string }) => unknown;\n  error?: Error | undefined;\n};\n\n/** The Composer props the adapter fills. */\nexport type ChatComposerBindings = {\n  status: ChatStatus;\n  onSubmit: (submission: ComposerSubmission) => void;\n  onStop: () => void;\n};\n\n/**\n * Wires AI SDK's `useChat` into Eased:\n *\n * ```tsx\n * const chat = useChat();\n * const { composer, messages, status } = fromUseChat(chat);\n * <Composer {...composer} />\n * ```\n *\n * A plain function (no hooks), so it is free to call in render.\n */\nexport function fromUseChat<M extends ChatMessage>(chat: UseChatLike<M>) {\n  const composer: ChatComposerBindings = {\n    status: chat.status,\n    onSubmit: ({ text, files }) => {\n      void chat.sendMessage(files.length ? { text, files } : { text });\n    },\n    onStop: () => void chat.stop(),\n  };\n  return {\n    messages: chat.messages,\n    status: chat.status,\n    error: chat.error,\n    composer,\n    regenerate: chat.regenerate,\n  };\n}\n\n/** Builds the submission the Composer passes to `onSubmit`. */\nexport function toSubmission(text: string, attachments: Attachment[]): ComposerSubmission {\n  return { text, attachments, files: attachments.map(toFilePart).filter((p): p is ChatFilePart => p !== null) };\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/use-chat-adapter.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chat-core/use-autosize.ts",
      "content": "\"use client\";\n\nimport { animate, useMotionValue, type AnimationPlaybackControls } from \"motion/react\";\nimport { useCallback, useLayoutEffect, useRef, type RefObject } from \"react\";\nimport { smooth, useSafeReducedMotion } from \"../../lib/motion\";\n\n/**\n * The composer's height spring, for a bare `<textarea>` with no shell of its own (Textarea\n * draws a bordered field; the composer draws the border around more than the text). The\n * textarea is sized at once, so the caret never leaves view, and the returned motion value\n * springs a clip around it. Put `height` on the clip's `style`.\n */\nexport function useAutosize(ref: RefObject<HTMLTextAreaElement | null>, { minRows = 1, maxRows = 6, value }: { minRows?: number; maxRows?: number; value?: string }) {\n  const reduced = useSafeReducedMotion();\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const spring = useRef<AnimationPlaybackControls | null>(null);\n\n  const resize = useCallback(\n    (animated: boolean) => {\n      const el = ref.current;\n      if (!el) return;\n      const cs = getComputedStyle(el);\n      const line = parseFloat(cs.lineHeight) || 20;\n      const chrome = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);\n      const max = maxRows * line + chrome;\n      el.style.height = \"0px\";\n      const content = el.scrollHeight;\n      const target = Math.round(Math.min(max, Math.max(minRows * line + chrome, content)));\n      el.style.height = `${target}px`;\n      el.style.overflowY = content > max + 1 ? \"auto\" : \"hidden\";\n      const from = height.get();\n      if (from === target) return;\n      spring.current?.stop();\n      if (!animated || reduced || from === \"auto\") height.jump(target);\n      else spring.current = animate(height, target, smooth);\n    },\n    [ref, minRows, maxRows, reduced, height],\n  );\n\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    resize(false);\n    let width = el.offsetWidth;\n    const ro = new ResizeObserver(() => {\n      if (el.offsetWidth === width) return;\n      width = el.offsetWidth;\n      resize(false);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, resize]);\n\n  useLayoutEffect(() => {\n    if (value !== undefined) resize(true);\n  }, [value, resize]);\n\n  return { height, resize };\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chat-core/use-autosize.ts"
    }
  ],
  "type": "registry:ui"
}