{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "dependencies": [],
  "description": "Revision-aware message composition with explicit submit receipts and recoverable rejected drafts.",
  "files": [
    {
      "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, type ComponentPropsWithoutRef, type CSSProperties, type ReactNode, type Ref } from \"react\";\nimport { shouldSubmitComposer } from \"./composer-state\";\nimport { useComposer, type ComposerOwnership, type ComposerSubmissionBehavior } from \"./use-composer\";\nimport styles from \"./composer.module.css\";\nimport accessibility from \"./accessibility.module.css\";\n\nexport type { ComposerDraft, ComposerReceipt } from \"./composer-state\";\nexport { reviseComposerDraft } from \"./composer-state\";\n\nexport type ComposerProps = ComposerOwnership & Omit<ComposerSubmissionBehavior, \"failureMessage\"> & {\n  actions?: ReactNode;\n  children?: ReactNode;\n  className?: string;\n  failureMessage?: string;\n  inputProps?: Omit<ComponentPropsWithoutRef<\"textarea\">, \"aria-label\" | \"children\" | \"defaultValue\" | \"disabled\" | \"form\" | \"onChange\" | \"placeholder\" | \"readOnly\" | \"value\">;\n  label: string;\n  labels?: { discard?: string; pending?: string; recovery?: string; retry?: string; send?: string; unsent?: string };\n  maxLength?: number;\n  placeholder?: string;\n  style?: CSSProperties;\n  textareaRef?: Ref<HTMLTextAreaElement>;\n};\n\n/** A native composer with one explicit, revision-bound submission at a time. */\nexport function Composer({\n  actions, children, className, failureMessage = \"Your message wasn’t sent. Try again.\",\n  inputProps, label, labels, maxLength, placeholder, style, textareaRef, ...options\n}: ComposerProps) {\n  const composer = useComposer({ ...options, failureMessage });\n  const input = useRef<HTMLTextAreaElement | null>(null);\n  const send = useRef<HTMLButtonElement | null>(null);\n  const recovery = useRef<HTMLDivElement | null>(null);\n  const recoveryHadFocus = useRef(false);\n  const id = useId();\n  const errorId = `${id}-error`;\n  useLayoutEffect(() => {\n    if (composer.failed == null && recoveryHadFocus.current) {\n      input.current?.focus({ preventScroll: true });\n      recoveryHadFocus.current = false;\n    }\n    const rememberFocus = (event: FocusEvent) => {\n      recoveryHadFocus.current = recovery.current?.contains(event.target as Node) ?? false;\n    };\n    document.addEventListener(\"focusin\", rememberFocus);\n    return () => document.removeEventListener(\"focusin\", rememberFocus);\n  }, [composer.failed]);\n  useLayoutEffect(() => {\n    const element = input.current;\n    if (element == null) return;\n    const resize = () => {\n      element.style.height = \"auto\";\n      element.style.height = `${element.scrollHeight}px`;\n    };\n    resize();\n    let width = element.clientWidth;\n    const observer = new ResizeObserver(() => {\n      if (element.clientWidth === width) return;\n      width = element.clientWidth;\n      resize();\n    });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [composer.draft.text]);\n\n  return (\n    <form\n      className={[styles.root, className].filter(Boolean).join(\" \")}\n      data-hui-part=\"composer\"\n      data-state={composer.pending ? \"submitting\" : composer.failed ? \"rejected\" : \"idle\"}\n      onSubmit={event => {\n        event.preventDefault();\n        if (!composer.canSubmit) return;\n        if (event.currentTarget.ownerDocument.activeElement === send.current) {\n          input.current?.focus({ preventScroll: true });\n        }\n        void composer.submit();\n      }}\n      style={style}\n    >\n      {children != null ? <div className={styles.context}>{children}</div> : null}\n      <textarea\n        {...inputProps}\n        aria-describedby={[...new Set([...(inputProps?.[\"aria-describedby\"]?.split(/\\s+/).filter(Boolean) ?? []), ...(composer.failed ? [errorId] : [])])].join(\" \") || undefined}\n        aria-label={label}\n        className={[styles.input, inputProps?.className].filter(Boolean).join(\" \")}\n        data-hui-part=\"composer-input\"\n        disabled={options.disabled}\n        form={undefined}\n        maxLength={maxLength ?? inputProps?.maxLength}\n        onChange={event => composer.setText(event.currentTarget.value)}\n        onKeyDown={event => {\n          inputProps?.onKeyDown?.(event);\n          if (event.defaultPrevented) return;\n          if (!shouldSubmitComposer(event.nativeEvent)) return;\n          event.preventDefault();\n          if (composer.canSubmit) event.currentTarget.form?.requestSubmit();\n        }}\n        placeholder={placeholder}\n        readOnly={options.readOnly}\n        ref={element => {\n          input.current = element;\n          if (typeof textareaRef === \"function\") return textareaRef(element);\n          if (textareaRef != null) textareaRef.current = element;\n        }}\n        rows={inputProps?.rows ?? 2}\n        value={composer.draft.text}\n      />\n      <div className={styles.footer}>\n        <div className={styles.actions}>{actions}</div>\n        <button className={styles.send} data-hui-part=\"composer-submit\" disabled={!composer.canSubmit} ref={send} type=\"submit\">\n          {composer.pending ? labels?.pending ?? \"Sending…\" : labels?.send ?? \"Send\"}\n        </button>\n      </div>\n      {composer.failed ? (\n        <div className={styles.failure} ref={recovery}>\n          <p id={errorId} role=\"alert\">{composer.failed.message}</p>\n          {composer.hasUnresolvedOtherDraft ? (\n            <>\n              <p>{labels?.recovery ?? \"Retry or discard the earlier message to send this draft.\"}</p>\n              <details>\n                <summary>{labels?.unsent ?? \"Review unsent message\"}</summary>\n                <p className={styles.unsent}>{composer.failed.draft.text}</p>\n              </details>\n            </>\n          ) : null}\n          <div className={styles.recovery}>\n            <button disabled={composer.pending || options.disabled || options.readOnly || options.submitDisabled} onClick={() => void composer.submit(true)} type=\"button\">{labels?.retry ?? \"Retry message\"}</button>\n            <button disabled={composer.pending} onClick={composer.discardFailure} type=\"button\">{labels?.discard ?? \"Discard failed attempt\"}</button>\n          </div>\n        </div>\n      ) : null}\n      <output aria-live=\"polite\" className={accessibility.visuallyHidden}>{composer.pending ? labels?.pending ?? \"Sending…\" : \"\"}</output>\n    </form>\n  );\n}\n",
      "path": "src/components/heidi-agent-ui/composer.tsx",
      "target": "components/heidi-agent-ui/composer.tsx",
      "type": "registry:file"
    },
    {
      "content": "/** A revision identifies an edit, including replacing text with the same text. */\nexport type ComposerDraft = Readonly<{ revision: number; text: string }>;\n\nexport type ComposerReceipt =\n  | { status: \"accepted\" }\n  | { message?: string; status: \"rejected\" };\n\nexport function reviseComposerDraft(draft: ComposerDraft, text: string): ComposerDraft {\n  return { revision: draft.revision + 1, text };\n}\n\nexport function isSameComposerDraft(left: ComposerDraft, right: ComposerDraft): boolean {\n  return left.revision === right.revision && left.text === right.text;\n}\n\n/** Acceptance clears only the submitted edit, never a newer draft. */\nexport function acceptComposerDraft(current: ComposerDraft, submitted: ComposerDraft): ComposerDraft {\n  return isSameComposerDraft(current, submitted) ? reviseComposerDraft(current, \"\") : current;\n}\n\nexport function shouldSubmitComposer(event: {\n  altKey: boolean;\n  ctrlKey: boolean;\n  isComposing: boolean;\n  key: string;\n  keyCode: number;\n  metaKey: boolean;\n  shiftKey: boolean;\n}): boolean {\n  return event.key === \"Enter\" && !event.isComposing && event.keyCode !== 229 &&\n    !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;\n}\n",
      "path": "src/components/heidi-agent-ui/composer-state.ts",
      "target": "components/heidi-agent-ui/composer-state.ts",
      "type": "registry:file"
    },
    {
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  acceptComposerDraft,\n  isSameComposerDraft,\n  reviseComposerDraft,\n  type ComposerDraft,\n  type ComposerReceipt\n} from \"./composer-state\";\n\nexport type ComposerOwnership =\n  | { defaultValue?: string; draft?: never; onDraftChange?: (draft: ComposerDraft) => void }\n  | { defaultValue?: never; draft: ComposerDraft; onDraftChange: (draft: ComposerDraft) => void };\n\nexport type ComposerSubmissionBehavior = {\n  disabled?: boolean;\n  failureMessage: string;\n  onSubmit: (draft: ComposerDraft) => ComposerReceipt | Promise<ComposerReceipt>;\n  /** Synchronous handoff: normal return takes custody; a throw keeps local recovery. */\n  onRejectedDraft?: (draft: ComposerDraft, message: string) => void;\n  readOnly?: boolean;\n  submitDisabled?: boolean;\n};\nexport type ComposerSubmissionOptions = ComposerOwnership & ComposerSubmissionBehavior;\n\ntype FailedSubmission = { draft: ComposerDraft; message: string };\n\n/** Owns one local submission intent. The host defines what acceptance means. */\nexport function useComposer(options: ComposerSubmissionOptions) {\n  const [localDraft, setLocalDraft] = useState<ComposerDraft>({ revision: 0, text: options.defaultValue ?? \"\" });\n  const draft = options.draft ?? localDraft;\n  const draftRef = useRef(draft);\n  const latestOptions = useRef(options);\n  const active = useRef<object | null>(null);\n  const mounted = useRef(false);\n  const [pending, setPending] = useState(false);\n  const [failed, setFailed] = useState<FailedSubmission | null>(null);\n  const failedRef = useRef<FailedSubmission | null>(null);\n\n  useLayoutEffect(() => { draftRef.current = draft; latestOptions.current = options; });\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n      active.current = null;\n    };\n  }, []);\n\n  function changeDraft(next: ComposerDraft) {\n    draftRef.current = next;\n    // Also rerender in controlled mode, so a rejected edit reasserts its owner.\n    setLocalDraft(next);\n    latestOptions.current.onDraftChange?.(next);\n  }\n\n  function setText(text: string) {\n    changeDraft(reviseComposerDraft(draftRef.current, text));\n  }\n\n  const hasUnresolvedOtherDraft = failed != null && !isSameComposerDraft(failed.draft, draft);\n  const unavailable = Boolean(options.disabled || options.readOnly || options.submitDisabled);\n  const canSubmit = !unavailable && !pending && !hasUnresolvedOtherDraft && draft.text.trim().length > 0;\n\n  async function submit(retry = false) {\n    const failure = failedRef.current;\n    const snapshot = { ...(retry && failure != null ? failure.draft : draftRef.current) };\n    if (unavailable || active.current != null || snapshot.text.trim() === \"\") return;\n    if (!retry && failure != null && !isSameComposerDraft(failure.draft, snapshot)) return;\n    const identity = {};\n    active.current = identity;\n    setPending(true);\n    let receipt: ComposerReceipt;\n    try {\n      try {\n        receipt = await options.onSubmit({ ...snapshot });\n      } catch {\n        receipt = { message: options.failureMessage, status: \"rejected\" };\n      }\n      if (!mounted.current || active.current !== identity) return;\n      if (receipt?.status !== \"accepted\") {\n        const rejected = { draft: snapshot, message: receipt?.status === \"rejected\" && receipt.message ? receipt.message : options.failureMessage };\n        const handoff = latestOptions.current.onRejectedDraft;\n        if (handoff) {\n          try {\n            handoff({ ...snapshot }, rejected.message);\n            // ponytail: a late host handoff must retire the retried local snapshot;\n            // clearing it before the callback returns would lose A if handoff throws.\n            if (failure != null && failedRef.current === failure) {\n              failedRef.current = null;\n              setFailed(null);\n            }\n            return;\n          } catch {\n            // Treat a thrown callback as a rejected handoff, retaining local recovery.\n          }\n        }\n        failedRef.current = rejected;\n        setFailed(rejected);\n        return;\n      }\n      failedRef.current = null;\n      setFailed(null);\n      const next = acceptComposerDraft(draftRef.current, snapshot);\n      if (next !== draftRef.current) changeDraft(next);\n    } finally {\n      if (mounted.current && active.current === identity) {\n        active.current = null;\n        setPending(false);\n      }\n    }\n  }\n\n  function discardFailure() {\n    if (active.current != null) return;\n    failedRef.current = null;\n    setFailed(null);\n  }\n\n  return { canSubmit, discardFailure, draft, failed, hasUnresolvedOtherDraft, pending, setText, submit };\n}\n",
      "path": "src/components/heidi-agent-ui/use-composer.ts",
      "target": "components/heidi-agent-ui/use-composer.ts",
      "type": "registry:file"
    },
    {
      "content": ".root {\n  box-sizing: border-box;\n  display: grid;\n  min-width: 0;\n  width: 100%;\n  padding: 0.875rem;\n  gap: 0.625rem;\n  border: 1px solid var(--hui-color-border-default, color-mix(in srgb, CanvasText 18%, Canvas));\n  border-radius: var(--hui-radius-lg, 1rem);\n  background: var(--hui-color-bg-elevated, Canvas);\n  color: var(--hui-color-fg-default, CanvasText);\n  font: inherit;\n}\n.root:focus-within { border-color: var(--hui-color-fg-muted, GrayText); }\n/* ponytail: keep the input's focus indicator inside its rounded container;\n   an outside ring is clipped when the host bounds an expanding composer. */\n.root:has(.input:focus-visible) { border-color: var(--hui-color-focus-ring, Highlight); outline: 2px solid var(--hui-color-focus-ring, Highlight); outline-offset: -2px; }\n.input {\n  box-sizing: border-box;\n  display: block;\n  width: 100%;\n  min-width: 0;\n  min-height: 3rem;\n  max-height: 12rem;\n  padding: 0.125rem;\n  border: 0;\n  box-shadow: none;\n  border-radius: 0;\n  resize: none;\n  overflow-y: auto;\n  overflow-wrap: anywhere;\n  background: transparent;\n  color: inherit;\n  font: inherit;\n  font-size: var(--hui-text-body-size, 1rem);\n  line-height: 1.5;\n}\n.input::placeholder { color: var(--hui-color-fg-muted, GrayText); }\n.input:focus-visible { outline: none; }\n.input:disabled { opacity: 0.55; }\n.context, .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; min-width: 0; overflow-wrap: anywhere; }\n.context > *, .actions > * { min-width: 0; max-width: 100%; }\n.footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem; min-width: 0; }\n.send {\n  box-sizing: border-box;\n  flex: 0 0 auto;\n  max-width: 100%;\n  min-height: 2.5rem;\n  margin-inline-start: auto;\n  padding: 0.5rem 1rem;\n  border: 1px solid transparent;\n  border-radius: 999px;\n  background: var(--hui-color-fg-default, CanvasText);\n  color: var(--hui-color-bg-elevated, Canvas);\n  font: inherit;\n  font-size: calc(var(--hui-text-body-size, 1rem) * 0.875);\n  font-weight: 500;\n  overflow-wrap: anywhere;\n  white-space: normal;\n  cursor: pointer;\n}\n.send:disabled { opacity: 0.4; cursor: default; }\n.send:focus-visible, .recovery button:focus-visible, .failure summary:focus-visible { outline: 2px solid var(--hui-color-focus-ring, Highlight); outline-offset: 3px; }\n.failure { display: grid; gap: 0.5rem; padding-top: 0.75rem; border-top: 1px solid var(--hui-color-border-default, color-mix(in srgb, CanvasText 18%, Canvas)); min-width: 0; font-size: calc(var(--hui-text-body-size, 1rem) * 0.875); line-height: 1.5; overflow-wrap: anywhere; }\n.failure p { margin: 0; }\n.failure summary { cursor: pointer; }\n.unsent { white-space: pre-wrap; max-height: 8rem; overflow: auto; margin-top: 0.5rem !important; }\n.recovery { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }\n.recovery button { box-sizing: border-box; max-width: 100%; min-height: 2.25rem; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; overflow-wrap: anywhere; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }\n.recovery button:disabled { opacity: 0.45; cursor: default; }\n@media (forced-colors: active) {\n  .root { border-color: CanvasText; }\n  .send { border-color: ButtonText; background: ButtonFace; color: ButtonText; }\n  .send:disabled { color: GrayText; opacity: 1; }\n}\n",
      "path": "src/components/heidi-agent-ui/composer.module.css",
      "target": "components/heidi-agent-ui/composer.module.css",
      "type": "registry:file"
    },
    {
      "content": "/** GENERATED from src/components/heidi-agent-ui/composer.module.css — do not edit by hand.\n * Rebuild: `bun run tokens:build`. */\ndeclare const styles: {\n  readonly \"actions\": string;\n  readonly \"context\": string;\n  readonly \"failure\": string;\n  readonly \"footer\": string;\n  readonly \"input\": string;\n  readonly \"recovery\": string;\n  readonly \"root\": string;\n  readonly \"send\": string;\n  readonly \"unsent\": string;\n};\nexport default styles;\n",
      "path": "src/components/heidi-agent-ui/composer.module.css.d.ts",
      "target": "components/heidi-agent-ui/composer.module.css.d.ts",
      "type": "registry:file"
    },
    {
      "content": "/* Shared by independently copied blocks; no host stylesheet is required. */\n.visuallyHidden {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n  border: 0;\n}\n",
      "path": "src/components/heidi-agent-ui/accessibility.module.css",
      "target": "components/heidi-agent-ui/accessibility.module.css",
      "type": "registry:file"
    },
    {
      "content": "/** GENERATED from src/components/heidi-agent-ui/accessibility.module.css — do not edit by hand.\n * Rebuild: `bun run tokens:build`. */\ndeclare const styles: {\n  readonly \"visuallyHidden\": string;\n};\nexport default styles;\n",
      "path": "src/components/heidi-agent-ui/accessibility.module.css.d.ts",
      "target": "components/heidi-agent-ui/accessibility.module.css.d.ts",
      "type": "registry:file"
    }
  ],
  "name": "heidi-composer",
  "registryDependencies": [],
  "title": "Composer",
  "type": "registry:block"
}
