{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "x-post",
  "dependencies": [
    "@radix-ui/react-slot",
    "@radix-ui/react-tooltip",
    "class-variance-authority",
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/watermelon-ui/x-post.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Heart, Share } from \"lucide-react\";\n\n\nexport interface XPostProps {\n  post: XPostData;\n  className?: string;\n  onAction?: (action: string, post: XPostData) => void;\n  responseActions?: ActionsProp;\n  onResponseAction?: (actionId: string) => void | Promise<void>;\n  onBeforeResponseAction?: (actionId: string) => boolean | Promise<boolean>;\n}\n\nfunction Avatar({ src, alt }: { src: string; alt: string }) {\n  return (\n\n    <img\n      src={src}\n      alt={alt}\n      width={40}\n      height={40}\n      className=\"size-10 shrink-0 rounded-full object-cover\"\n    />\n  );\n}\n\nfunction XLogo({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 300 271\"\n      className={className}\n      role=\"img\"\n      aria-label=\"X (formerly Twitter) logo\"\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"m236 0h46l-101 115 118 156h-92.6l-72.5-94.8-83 94.8h-46l107-123-113-148h94.9l65.5 86.6zm-16.1 244h25.5l-165-218h-27.4z\"\n      />\n    </svg>\n  );\n}\n\nfunction VerifiedBadge({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      className={className}\n      role=\"img\"\n      aria-label=\"Verified account\"\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.818-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.437 2.25c-.415-.165-.866-.25-1.336-.25-2.11 0-3.818 1.79-3.818 4 0 .495.083.965.238 1.4-1.272.65-2.147 2.018-2.147 3.6 0 1.495.782 2.798 1.942 3.486-.02.17-.032.34-.032.514 0 2.21 1.708 4 3.818 4 .47 0 .92-.086 1.335-.25.62 1.334 1.926 2.25 3.437 2.25 1.512 0 2.818-.916 3.437-2.25.415.163.865.248 1.336.248 2.11 0 3.818-1.79 3.818-4 0-.174-.012-.344-.033-.513 1.158-.687 1.943-1.99 1.943-3.484zm-6.616-3.334l-4.334 6.5c-.145.217-.382.334-.625.334-.143 0-.288-.04-.416-.126l-.115-.094-2.415-2.415c-.293-.293-.293-.768 0-1.06s.768-.294 1.06 0l1.77 1.767 3.825-5.74c.23-.345.696-.436 1.04-.207.346.23.44.696.21 1.04z\"\n      />\n    </svg>\n  );\n}\n\nfunction AuthorInfo({\n  name,\n  handle,\n  verified,\n  createdAt,\n}: {\n  name: string;\n  handle: string;\n  verified?: boolean;\n  createdAt?: string;\n}) {\n  return (\n    <div className=\"flex min-w-0 items-center gap-1\">\n      <span className=\"truncate font-semibold\">{name}</span>\n      {verified && (\n        <VerifiedBadge className=\"size-[18px] shrink-0 text-blue-500\" />\n      )}\n      <span className=\"text-muted-foreground truncate\">@{handle}</span>\n      {createdAt && (\n        <>\n          <span className=\"text-muted-foreground\">·</span>\n          <span className=\"text-muted-foreground\">\n            {formatRelativeTime(createdAt)}\n          </span>\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction PostBody({ text }: { text?: string }) {\n  if (!text) return null;\n  return (\n    <p className=\"text-[15px] leading-normal text-pretty wrap-break-word whitespace-pre-wrap\">\n      {text}\n    </p>\n  );\n}\n\nfunction PostMedia({\n  media,\n  onOpen,\n}: {\n  media: XPostMedia;\n  onOpen?: () => void;\n}) {\n  const aspectRatio =\n    media.aspectRatio === \"1:1\"\n      ? \"1\"\n      : media.aspectRatio === \"4:3\"\n        ? \"4/3\"\n        : \"16/9\";\n\n  return (\n    <button\n      type=\"button\"\n      className=\"bg-muted mt-2 w-full overflow-hidden rounded-xl\"\n      style={{ aspectRatio }}\n      onClick={() => onOpen?.()}\n    >\n      {media.type === \"image\" ? (\n\n        <img\n          src={media.url}\n          alt={media.alt}\n          className=\"size-full object-cover\"\n          loading=\"lazy\"\n        />\n      ) : (\n        <video\n          src={media.url}\n          controls\n          playsInline\n          className=\"size-full object-contain\"\n        />\n      )}\n    </button>\n  );\n}\n\nfunction PostLinkPreview({ preview }: { preview: XPostLinkPreview }) {\n  const domain = preview.domain ?? getDomain(preview.url);\n\n  return (\n    <a\n      href={preview.url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"hover:bg-muted/50 mt-2 block overflow-hidden rounded-xl border transition-colors\"\n    >\n      {preview.imageUrl && (\n\n        <img\n          src={preview.imageUrl}\n          alt=\"\"\n          className=\"h-48 w-full object-cover\"\n          loading=\"lazy\"\n        />\n      )}\n      <div className=\"p-3\">\n        {domain && (\n          <div className=\"text-muted-foreground text-xs\">{domain}</div>\n        )}\n        {preview.title && (\n          <div className=\"font-medium text-pretty\">{preview.title}</div>\n        )}\n        {preview.description && (\n          <div className=\"text-muted-foreground line-clamp-2 text-sm text-pretty\">\n            {preview.description}\n          </div>\n        )}\n      </div>\n    </a>\n  );\n}\n\nfunction QuotedPostCard({ post }: { post: XPostData }) {\n  return (\n    <div className=\"hover:bg-muted/30 mt-2 rounded-xl border p-3 transition-colors\">\n      <div className=\"flex min-w-0 items-center gap-1\">\n\n        <img\n          src={post.author.avatarUrl}\n          alt={`${post.author.name} avatar`}\n          width={16}\n          height={16}\n          className=\"size-4 rounded-full object-cover\"\n        />\n        <span className=\"truncate font-semibold\">{post.author.name}</span>\n        {post.author.verified && (\n          <VerifiedBadge className=\"size-3.5 shrink-0 text-blue-500\" />\n        )}\n        <span className=\"text-muted-foreground truncate\">\n          @{post.author.handle}\n        </span>\n        {post.createdAt && (\n          <>\n            <span className=\"text-muted-foreground shrink-0\">·</span>\n            <span className=\"text-muted-foreground shrink-0\">\n              {formatRelativeTime(post.createdAt)}\n            </span>\n          </>\n        )}\n      </div>\n      {post.text && <p className=\"mt-1.5\">{post.text}</p>}\n      {post.media && (\n\n        <img\n          src={post.media.url}\n          alt={post.media.alt}\n          className=\"mt-2 rounded-lg\"\n        />\n      )}\n    </div>\n  );\n}\n\nfunction ActionButton({\n  icon: Icon,\n  label,\n  count,\n  active,\n  hoverColor,\n  activeColor,\n  onClick,\n}: {\n  icon: React.ComponentType<{ className?: string }>;\n  label: string;\n  count?: number;\n  active?: boolean;\n  hoverColor: string;\n  activeColor?: string;\n  onClick: () => void;\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onClick();\n          }}\n          className={cn(\n            \"h-auto gap-1.5 px-2 py-1\",\n            hoverColor,\n            active && activeColor,\n          )}\n          aria-label={label}\n        >\n          <Icon className=\"size-4\" />\n          {count !== undefined && (\n            <span className=\"text-sm\">{formatCount(count)}</span>\n          )}\n        </Button>\n      </TooltipTrigger>\n      <TooltipContent>{label}</TooltipContent>\n    </Tooltip>\n  );\n}\n\nfunction PostActions({\n  stats,\n  onAction,\n}: {\n  stats?: XPostData[\"stats\"];\n  onAction: (action: string) => void;\n}) {\n  return (\n    <TooltipProvider delayDuration={300}>\n      <div className=\"mt-3 flex items-center gap-4\">\n        <ActionButton\n          icon={Heart}\n          label=\"Like\"\n          count={stats?.likes}\n          active={stats?.isLiked}\n          hoverColor=\"hover:text-pink-500 hover:bg-pink-500/10\"\n          activeColor=\"text-pink-500 fill-pink-500\"\n          onClick={() => onAction(\"like\")}\n        />\n        <ActionButton\n          icon={Share}\n          label=\"Share\"\n          hoverColor=\"hover:text-blue-500 hover:bg-blue-500/10\"\n          onClick={() => onAction(\"share\")}\n        />\n      </div>\n    </TooltipProvider>\n  );\n}\n\nexport function XPost({\n  post,\n  className,\n  onAction,\n  responseActions,\n  onResponseAction,\n  onBeforeResponseAction,\n}: XPostProps) {\n  const normalizedFooterActions = React.useMemo(\n    () => normalizeActionsConfig(responseActions),\n    [responseActions],\n  );\n\n  return (\n    <div\n      className={cn(\"flex max-w-xl flex-col gap-3\", className)}\n      data-tool-ui-id={post.id}\n      data-slot=\"x-post\"\n    >\n      <article className=\"bg-card rounded-xl border p-3 shadow-sm\">\n        <div className=\"flex gap-3\">\n          <Avatar\n            src={post.author.avatarUrl}\n            alt={`${post.author.name} avatar`}\n          />\n          <div className=\"min-w-0 flex-1\">\n            <div className=\"flex items-start justify-between gap-2\">\n              <AuthorInfo\n                name={post.author.name}\n                handle={post.author.handle}\n                verified={post.author.verified}\n                createdAt={post.createdAt}\n              />\n              <XLogo className=\"text-muted-foreground/40 size-4\" />\n            </div>\n            <PostBody text={post.text} />\n            {post.media && <PostMedia media={post.media} />}\n            {post.quotedPost && <QuotedPostCard post={post.quotedPost} />}\n            {post.linkPreview && !post.quotedPost && (\n              <PostLinkPreview preview={post.linkPreview} />\n            )}\n            <PostActions\n              stats={post.stats}\n              onAction={(action) => onAction?.(action, post)}\n            />\n          </div>\n        </div>\n      </article>\n\n      {normalizedFooterActions && (\n        <div className=\"@container/actions\">\n          <ActionButtons\n            actions={normalizedFooterActions.items}\n            align={normalizedFooterActions.align}\n            confirmTimeout={normalizedFooterActions.confirmTimeout}\n            onAction={(id) => onResponseAction?.(id)}\n            onBeforeAction={onBeforeResponseAction}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\nimport { z } from \"zod\";\n\nfunction formatZodPath(path: Array<string | number | symbol>): string {\n  if (path.length === 0) return \"root\";\n  return path\n    .map((segment) =>\n      typeof segment === \"number\" ? `[${segment}]` : String(segment),\n    )\n    .join(\".\");\n}\n\n/**\n * Format Zod errors into a compact `path: message` string.\n */\nexport function formatZodError(error: z.ZodError): string {\n  const parts = error.issues.map((issue) => {\n    const path = formatZodPath(issue.path);\n    return `${path}: ${issue.message}`;\n  });\n\n  return Array.from(new Set(parts)).join(\"; \");\n}\n\n/**\n * Parse unknown input and throw a readable error.\n */\nexport function parseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n  name: string,\n): T {\n  const res = schema.safeParse(input);\n  if (!res.success) {\n    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);\n  }\n  return res.data;\n}\n\n\nexport const XPostAuthorSchema = z.object({\n  name: z.string(),\n  handle: z.string(),\n  avatarUrl: z.string().url(),\n  verified: z.boolean().optional(),\n});\n\nexport const XPostMediaSchema = z.object({\n  type: z.enum([\"image\", \"video\"]),\n  url: z.string().url(),\n  alt: z.string(),\n  aspectRatio: z.enum([\"1:1\", \"4:3\", \"16:9\", \"9:16\"]).optional(),\n});\n\nexport const XPostLinkPreviewSchema = z.object({\n  url: z.string().url(),\n  title: z.string().optional(),\n  description: z.string().optional(),\n  imageUrl: z.string().url().optional(),\n  domain: z.string().optional(),\n});\n\nexport const XPostStatsSchema = z.object({\n  likes: z.number().optional(),\n  isLiked: z.boolean().optional(),\n  isReposted: z.boolean().optional(),\n  isBookmarked: z.boolean().optional(),\n});\n\nexport interface XPostData {\n  id: string;\n  author: z.infer<typeof XPostAuthorSchema>;\n  text?: string;\n  media?: z.infer<typeof XPostMediaSchema>;\n  linkPreview?: z.infer<typeof XPostLinkPreviewSchema>;\n  quotedPost?: XPostData;\n  stats?: z.infer<typeof XPostStatsSchema>;\n  createdAt?: string;\n}\n\nexport const SerializableXPostSchema: z.ZodType<XPostData> = z.object({\n  id: z.string(),\n  author: XPostAuthorSchema,\n  text: z.string().optional(),\n  media: XPostMediaSchema.optional(),\n  linkPreview: XPostLinkPreviewSchema.optional(),\n  quotedPost: z.lazy(() => SerializableXPostSchema).optional(),\n  stats: XPostStatsSchema.optional(),\n  createdAt: z.string().optional(),\n});\nexport type XPostAuthor = z.infer<typeof XPostAuthorSchema>;\nexport type XPostMedia = z.infer<typeof XPostMediaSchema>;\nexport type XPostLinkPreview = z.infer<typeof XPostLinkPreviewSchema>;\nexport type XPostStats = z.infer<typeof XPostStatsSchema>;\n\nexport function parseSerializableXPost(input: unknown): XPostData {\n  return parseWithSchema(SerializableXPostSchema, input, \"XPost\");\n}\n\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n        \"icon-sm\": \"size-8\",\n        \"icon-lg\": \"size-10\",\n        homeCTA: \"rounded-full px-6 py-3 text-lg w-fit text-start\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nfunction Button({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean;\n  }) {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nexport { Button, buttonVariants };\n\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n\n\nexport interface ActionButtonsProps {\n  actions: Action[];\n  onAction: (actionId: string) => void | Promise<void>;\n  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;\n  confirmTimeout?: number;\n  align?: \"left\" | \"center\" | \"right\";\n  className?: string;\n}\n\nexport function ActionButtons({\n  actions,\n  onAction,\n  onBeforeAction,\n  confirmTimeout = 3000,\n  align = \"right\",\n  className,\n}: ActionButtonsProps) {\n  const { actions: resolvedActions, runAction } = useActionButtons({\n    actions,\n    onAction,\n    onBeforeAction,\n    confirmTimeout,\n  });\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-3\",\n        \"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2\",\n        align === \"left\" && \"@sm/actions:justify-start\",\n        align === \"center\" && \"@sm/actions:justify-center\",\n        align === \"right\" && \"@sm/actions:justify-end\",\n        className,\n      )}\n    >\n      {resolvedActions.map((action) => {\n        const label = action.currentLabel;\n        const variant = action.variant || \"default\";\n\n        return (\n          <Button\n            key={action.id}\n            variant={variant}\n            onClick={() => runAction(action.id)}\n            disabled={action.isDisabled}\n            className={cn(\n              \"rounded-full px-4!\",\n              \"justify-center\",\n              \"min-h-11 w-full text-base\",\n              \"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm\",\n              action.isConfirming &&\n              \"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse\",\n            )}\n            aria-label={\n              action.shortcut ? `${label} (${action.shortcut})` : label\n            }\n          >\n            {action.isLoading && (\n              <svg\n                className=\"mr-2 h-4 w-4 motion-safe:animate-spin\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n              >\n                <circle\n                  className=\"opacity-25\"\n                  cx=\"12\"\n                  cy=\"12\"\n                  r=\"10\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"4\"\n                />\n                <path\n                  className=\"opacity-75\"\n                  fill=\"currentColor\"\n                  d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n                />\n              </svg>\n            )}\n            {action.icon && !action.isLoading && (\n              <span className=\"mr-2\">{action.icon}</span>\n            )}\n            {label}\n            {action.shortcut && !action.isLoading && (\n              <kbd className=\"border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block\">\n                {action.shortcut}\n              </kbd>\n            )}\n          </Button>\n        );\n      })}\n    </div>\n  );\n}\n\n\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\n\nexport type UseActionButtonsOptions = {\n  actions: Action[];\n  onAction: (actionId: string) => void | Promise<void>;\n  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;\n  confirmTimeout?: number;\n};\n\nexport type UseActionButtonsResult = {\n  actions: Array<\n    Action & {\n      currentLabel: string;\n      isConfirming: boolean;\n      isExecuting: boolean;\n      isDisabled: boolean;\n      isLoading: boolean;\n    }\n  >;\n  runAction: (actionId: string) => Promise<void>;\n  confirmingActionId: string | null;\n  executingActionId: string | null;\n};\n\nexport function useActionButtons(\n  options: UseActionButtonsOptions,\n): UseActionButtonsResult {\n  const {\n    actions,\n    onAction,\n    onBeforeAction,\n    confirmTimeout = 3000,\n  } = options;\n\n  const [confirmingActionId, setConfirmingActionId] = useState<string | null>(\n    null,\n  );\n  const [executingActionId, setExecutingActionId] = useState<string | null>(\n    null,\n  );\n\n  useEffect(() => {\n    if (!confirmingActionId) return;\n    const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);\n    return () => clearTimeout(id);\n  }, [confirmingActionId, confirmTimeout]);\n\n  useEffect(() => {\n    if (!confirmingActionId) return;\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        setConfirmingActionId(null);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [confirmingActionId]);\n\n  const runAction = useCallback(\n    async (actionId: string) => {\n      const action = actions.find((a) => a.id === actionId);\n      if (!action) return;\n\n      const isAnyActionExecuting = executingActionId !== null;\n      if (action.disabled || action.loading || isAnyActionExecuting) {\n        return;\n      }\n\n      if (action.confirmLabel && confirmingActionId !== action.id) {\n        setConfirmingActionId(action.id);\n        return;\n      }\n\n      if (onBeforeAction) {\n        const shouldProceed = await onBeforeAction(action.id);\n        if (!shouldProceed) {\n          setConfirmingActionId(null);\n          return;\n        }\n      }\n\n      try {\n        setExecutingActionId(action.id);\n        await onAction(action.id);\n      } finally {\n        setExecutingActionId(null);\n        setConfirmingActionId(null);\n      }\n    },\n    [actions, confirmingActionId, executingActionId, onAction, onBeforeAction],\n  );\n\n  const resolvedActions = useMemo(\n    () =>\n      actions.map((action) => {\n        const isConfirming = confirmingActionId === action.id;\n        const isThisActionExecuting = executingActionId === action.id;\n        const isLoading = action.loading || isThisActionExecuting;\n        const isDisabled =\n          action.disabled || (executingActionId !== null && !isThisActionExecuting);\n        const currentLabel =\n          isConfirming && action.confirmLabel\n            ? action.confirmLabel\n            : action.label;\n\n        return {\n          ...action,\n          currentLabel,\n          isConfirming,\n          isExecuting: isThisActionExecuting,\n          isDisabled,\n          isLoading,\n        };\n      }),\n    [actions, confirmingActionId, executingActionId],\n  );\n\n  return {\n    actions: resolvedActions,\n    runAction,\n    confirmingActionId,\n    executingActionId,\n  };\n}\n\n\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Tool UI conventions:\n * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).\n * - Schema: `SerializableXSchema`\n * - Parser: `parseSerializableX(input: unknown)`\n * - Actions: `responseActions`, `onResponseAction`, `onBeforeResponseAction`\n * - Root attrs: `data-tool-ui-id` + `data-slot`\n */\n\n/**\n * Schema for tool UI identity.\n *\n * Every tool UI should have a unique identifier that:\n * - Is stable across re-renders\n * - Is meaningful (not auto-generated)\n * - Is unique within the conversation\n *\n * Format recommendation: `{component-type}-{semantic-identifier}`\n * Examples: \"data-table-expenses-q3\", \"option-list-deploy-target\"\n */\nexport const ToolUIIdSchema = z.string().min(1);\n\nexport type ToolUIId = z.infer<typeof ToolUIIdSchema>;\n\n/**\n * Primary role of a Tool UI surface in a chat context.\n */\nexport const ToolUIRoleSchema = z.enum([\n  \"information\",\n  \"decision\",\n  \"control\",\n  \"state\",\n  \"composite\",\n]);\n\nexport type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;\n\nexport const ToolUIReceiptOutcomeSchema = z.enum([\n  \"success\",\n  \"partial\",\n  \"failed\",\n  \"cancelled\",\n]);\n\nexport type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;\n\n/**\n * Optional receipt metadata: a durable summary of an outcome.\n */\nexport const ToolUIReceiptSchema = z.object({\n  outcome: ToolUIReceiptOutcomeSchema,\n  summary: z.string().min(1),\n  identifiers: z.record(z.string(), z.string()).optional(),\n  at: z.string().datetime(),\n});\n\nexport type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;\n\n/**\n * Base schema for Tool UI payloads (id + optional role/receipt).\n */\nexport const ToolUISurfaceSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n});\n\nexport type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;\n\nexport const ActionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  /**\n   * Canonical narration the assistant can use after this action is taken.\n   *\n   * Example: \"I exported the table as CSV.\" / \"I opened the link in a new tab.\"\n   */\n  sentence: z.string().optional(),\n  confirmLabel: z.string().optional(),\n  variant: z\n    .enum([\"default\", \"destructive\", \"secondary\", \"ghost\", \"outline\"])\n    .optional(),\n  icon: z.custom<ReactNode>().optional(),\n  loading: z.boolean().optional(),\n  disabled: z.boolean().optional(),\n  shortcut: z.string().optional(),\n});\n\nexport type Action = z.infer<typeof ActionSchema>;\n\nexport const ActionButtonsPropsSchema = z.object({\n  actions: z.array(ActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n  className: z.string().optional(),\n});\n\nexport const SerializableActionSchema = ActionSchema.omit({ icon: true });\nexport const SerializableActionsSchema = ActionButtonsPropsSchema.extend({\n  actions: z.array(SerializableActionSchema),\n}).omit({ className: true });\n\nexport interface ActionsConfig {\n  items: Action[];\n  align?: \"left\" | \"center\" | \"right\";\n  confirmTimeout?: number;\n}\n\nexport const SerializableActionsConfigSchema = z.object({\n  items: z.array(SerializableActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n});\n\nexport type SerializableActionsConfig = z.infer<\n  typeof SerializableActionsConfigSchema\n>;\n\nexport type SerializableAction = z.infer<typeof SerializableActionSchema>;\n\n\nexport type ActionsProp = ActionsConfig | Action[];\n\nconst NEGATORY_ACTION_IDS = new Set([\n  \"cancel\",\n  \"dismiss\",\n  \"skip\",\n  \"no\",\n  \"reset\",\n  \"close\",\n  \"decline\",\n  \"reject\",\n  \"back\",\n  \"later\",\n  \"not-now\",\n  \"maybe-later\",\n]);\n\nfunction inferVariant(action: Action): Action {\n  if (action.variant) return action;\n  if (NEGATORY_ACTION_IDS.has(action.id)) {\n    return { ...action, variant: \"ghost\" };\n  }\n  return action;\n}\n\nexport function normalizeActionsConfig(\n  actions?: ActionsProp,\n): ActionsConfig | null {\n  if (!actions) return null;\n\n  const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);\n\n  if (rawItems.length === 0) {\n    return null;\n  }\n\n  const items = rawItems.map(inferVariant);\n\n  return Array.isArray(actions)\n    ? { items }\n    : {\n      items,\n      align: actions.align,\n      confirmTimeout: actions.confirmTimeout,\n    };\n}\n\nexport function formatRelativeTime(iso: string): string {\n  const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);\n  if (seconds < 60) return `${seconds}s`;\n  if (seconds < 3600) return `${Math.round(seconds / 60)}m`;\n  if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;\n  if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;\n  return `${Math.round(seconds / 604800)}w`;\n}\n\nexport function formatCount(count: number): string {\n  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;\n  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;\n  return String(count);\n}\n\nexport function getDomain(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return \"\";\n  }\n}\n\nexport function prefersReducedMotion(): boolean {\n  return (\n    typeof window !== \"undefined\" &&\n    window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/x-post.tsx"
    }
  ],
  "type": "registry:ui"
}
