{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-scroller-story",
  "title": "Message Scroller Story",
  "author": "Lloyd Richards <lloyd.d.richards@gmail.com>",
  "description": "Storybook stories demonstrating message scroller anchoring, streaming, history loading, and jump controls",
  "dependencies": [
    "@shadcn/react"
  ],
  "registryDependencies": [
    "message-scroller",
    "message",
    "bubble",
    "button",
    "marker"
  ],
  "files": [
    {
      "path": "registry/ui/message-scroller-story/message-scroller-radix.stories.tsx",
      "content": "// Replace nextjs-vite with the name of your framework\nimport type { Meta, StoryObj } from \"@storybook/nextjs-vite\";\nimport * as React from \"react\";\nimport { expect, userEvent, within } from \"storybook/test\";\n\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport { Marker, MarkerContent } from \"@/components/ui/marker\";\nimport { Message, MessageContent } from \"@/components/ui/message\";\nimport {\n  MessageScroller,\n  MessageScrollerButton,\n  MessageScrollerContent,\n  MessageScrollerItem,\n  MessageScrollerProvider,\n  MessageScrollerViewport,\n  useMessageScroller,\n  useMessageScrollerScrollable,\n} from \"@/components/ui/message-scroller\";\n\n/**\n * Provides chat transcript scrolling with anchoring, jump controls, and live-edge behavior.\n */\nconst meta: Meta<typeof MessageScroller> = {\n  title: \"ui/radix/MessageScroller\",\n  component: MessageScroller,\n  tags: [\"autodocs\"],\n  parameters: {\n    layout: \"centered\",\n  },\n  decorators: (Story) => (\n    <div className=\"h-96 w-full min-w-sm max-w-lg rounded-md border\">\n      <Story />\n    </div>\n  ),\n} satisfies Meta<typeof MessageScroller>;\n\nexport default meta;\n\ntype Story = StoryObj<typeof meta>;\n\ntype TranscriptMessage = {\n  id: string;\n  role: \"user\" | \"assistant\";\n  content: string;\n};\n\nconst transcript: TranscriptMessage[] = [\n  {\n    id: \"m1\",\n    role: \"user\",\n    content: \"Can you review the activation dip after workspace creation?\",\n  },\n  {\n    id: \"m2\",\n    role: \"assistant\",\n    content:\n      \"The sharpest drop is between creating the workspace and inviting the first teammate.\",\n  },\n  {\n    id: \"m3\",\n    role: \"user\",\n    content: \"What should I compare before we change the onboarding flow?\",\n  },\n  {\n    id: \"m4\",\n    role: \"assistant\",\n    content:\n      \"Compare template users, blank workspace users, and people who skip invites but return within 24 hours.\",\n  },\n  {\n    id: \"m5\",\n    role: \"user\",\n    content: \"Can you turn that into an experiment?\",\n  },\n  {\n    id: \"m6\",\n    role: \"assistant\",\n    content:\n      \"Create a variant that shows a short checklist after workspace creation, then measure first invite completion and 24-hour return rate.\",\n  },\n];\n\nfunction StoryShell({\n  children,\n  controls,\n}: {\n  children: React.ReactNode;\n  controls?: React.ReactNode;\n}) {\n  return (\n    <div className=\"flex size-full min-h-0 flex-col overflow-hidden\">\n      <div className=\"min-h-0 flex-1 overflow-hidden\">{children}</div>\n      {controls ? (\n        <div className=\"shrink-0 border-t p-2\">{controls}</div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction ScrollerSlot({ children }: { children: React.ReactNode }) {\n  return <div className=\"size-full min-h-0 overflow-hidden\">{children}</div>;\n}\n\nfunction RestoreMessagePosition({\n  messageId,\n  onRestored,\n}: {\n  messageId: string | null;\n  onRestored: () => void;\n}) {\n  const { scrollToMessage } = useMessageScroller();\n\n  React.useLayoutEffect(() => {\n    if (!messageId) {\n      return;\n    }\n\n    const frame = window.requestAnimationFrame(() => {\n      scrollToMessage(messageId, { align: \"start\", behavior: \"auto\" });\n      onRestored();\n    });\n\n    return () => window.cancelAnimationFrame(frame);\n  }, [messageId, onRestored, scrollToMessage]);\n\n  return null;\n}\n\nfunction TranscriptMessage({ message }: { message: TranscriptMessage }) {\n  const isUser = message.role === \"user\";\n\n  return (\n    <Message align={isUser ? \"end\" : \"start\"}>\n      <MessageContent>\n        <Bubble variant={isUser ? \"default\" : \"secondary\"}>\n          <BubbleContent>{message.content}</BubbleContent>\n        </Bubble>\n      </MessageContent>\n    </Message>\n  );\n}\n\nfunction Transcript({\n  messages = transcript,\n  anchorRole = \"user\",\n}: {\n  messages?: TranscriptMessage[];\n  anchorRole?: TranscriptMessage[\"role\"];\n}) {\n  return (\n    <MessageScroller>\n      <MessageScrollerViewport>\n        <MessageScrollerContent>\n          {messages.map((message) => (\n            <MessageScrollerItem\n              key={message.id}\n              messageId={message.id}\n              scrollAnchor={message.role === anchorRole}\n            >\n              <TranscriptMessage message={message} />\n            </MessageScrollerItem>\n          ))}\n        </MessageScrollerContent>\n      </MessageScrollerViewport>\n      <MessageScrollerButton />\n    </MessageScroller>\n  );\n}\n\n/**\n * The default transcript scrolls within a height-constrained container.\n */\nexport const Default: Story = {\n  render: () => (\n    <MessageScrollerProvider>\n      <Transcript />\n    </MessageScrollerProvider>\n  ),\n};\n\n/**\n * Anchor new turns to user messages so the prompt starts near the top edge.\n */\nexport const AnchoringTurns: Story = {\n  render: () => {\n    const [messages, setMessages] = React.useState(transcript.slice(0, 2));\n\n    return (\n      <StoryShell\n        controls={\n          <Button\n            size=\"sm\"\n            onClick={() =>\n              setMessages((current) => [\n                ...current,\n                {\n                  id: `user-${current.length}`,\n                  role: \"user\",\n                  content: \"What should we inspect next?\",\n                },\n                {\n                  id: `assistant-${current.length}`,\n                  role: \"assistant\",\n                  content: \"Start with the story coverage and registry output.\",\n                },\n              ])\n            }\n          >\n            Send Message\n          </Button>\n        }\n      >\n        <ScrollerSlot>\n          <MessageScrollerProvider autoScroll>\n            <Transcript messages={messages} />\n          </MessageScrollerProvider>\n        </ScrollerSlot>\n      </StoryShell>\n    );\n  },\n};\n\n/**\n * Auto-scroll follows the live edge while a scripted response streams in.\n */\nexport const Streaming: Story = {\n  render: () => {\n    const [reply, setReply] = React.useState(\"Ready to stream.\");\n\n    return (\n      <StoryShell\n        controls={\n          <Button\n            size=\"sm\"\n            onClick={() =>\n              setReply(\n                \"Streaming is simulated. The reply grows while the scroller keeps the live edge in view.\",\n              )\n            }\n          >\n            Send\n          </Button>\n        }\n      >\n        <ScrollerSlot>\n          <MessageScrollerProvider autoScroll>\n            <Transcript\n              messages={[\n                ...transcript.slice(0, 3),\n                {\n                  id: \"streaming\",\n                  role: \"assistant\",\n                  content: reply,\n                },\n              ]}\n            />\n          </MessageScrollerProvider>\n        </ScrollerSlot>\n      </StoryShell>\n    );\n  },\n};\n\n/**\n * Open a saved transcript at the last anchored turn.\n */\nexport const OpeningPosition: Story = {\n  render: () => (\n    <MessageScrollerProvider defaultScrollPosition=\"last-anchor\">\n      <Transcript />\n    </MessageScrollerProvider>\n  ),\n};\n\n/**\n * Prepend earlier messages without moving the reader away from the visible row.\n */\nexport const LoadHistory: Story = {\n  render: () => {\n    const earlier: TranscriptMessage[] = [\n      {\n        id: \"h1\",\n        role: \"user\",\n        content: \"Did the deploy include billing changes?\",\n      },\n      {\n        id: \"h2\",\n        role: \"assistant\",\n        content: \"No. The app deploy only changed the export queue worker.\",\n      },\n    ];\n    const [messages, setMessages] = React.useState(transcript.slice(2));\n    const [restoreMessageId, setRestoreMessageId] = React.useState<\n      string | null\n    >(null);\n    const restoreComplete = React.useCallback(() => {\n      setRestoreMessageId(null);\n    }, []);\n\n    return (\n      <MessageScrollerProvider defaultScrollPosition=\"start\">\n        <StoryShell\n          controls={\n            <Button\n              size=\"sm\"\n              onClick={() => {\n                setRestoreMessageId(messages[0]?.id ?? null);\n                setMessages((current) => [...earlier, ...current]);\n              }}\n            >\n              Load History\n            </Button>\n          }\n        >\n          <ScrollerSlot>\n            <Transcript messages={messages} />\n            <RestoreMessagePosition\n              messageId={restoreMessageId}\n              onRestored={restoreComplete}\n            />\n          </ScrollerSlot>\n        </StoryShell>\n      </MessageScrollerProvider>\n    );\n  },\n};\n\nfunction JumpControls() {\n  const { scrollToMessage, scrollToEnd } = useMessageScroller();\n  const [status, setStatus] = React.useState(\"Ready to jump\");\n\n  function jumpTo(messageId: string, label: string) {\n    const didScroll = scrollToMessage(messageId, { align: \"start\" });\n    setStatus(didScroll ? `Jumped to ${label}` : `${label} is not available`);\n  }\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-2 border-b p-2\">\n      <Button\n        size=\"sm\"\n        variant=\"outline\"\n        onClick={() => jumpTo(\"m1\", \"first message\")}\n      >\n        First\n      </Button>\n      <Button\n        size=\"sm\"\n        variant=\"outline\"\n        onClick={() => jumpTo(\"m5\", \"latest question\")}\n      >\n        Latest Question\n      </Button>\n      <Button\n        size=\"sm\"\n        onClick={() => {\n          const didScroll = scrollToEnd();\n          setStatus(didScroll ? \"Jumped to end\" : \"Already at end\");\n        }}\n      >\n        End\n      </Button>\n      <Marker variant=\"separator\" className=\"ml-auto\">\n        <MarkerContent>{status}</MarkerContent>\n      </Marker>\n    </div>\n  );\n}\n\n/**\n * Use external controls to jump to stable message ids.\n */\nexport const JumpToMessage: Story = {\n  render: () => (\n    <MessageScrollerProvider>\n      <div className=\"flex size-full min-h-0 flex-col overflow-hidden\">\n        <JumpControls />\n        <div className=\"min-h-0 flex-1 overflow-hidden\">\n          <Transcript />\n        </div>\n      </div>\n    </MessageScrollerProvider>\n  ),\n};\n\nfunction ScrollStatus() {\n  const scrollable = useMessageScrollerScrollable();\n\n  return (\n    <Marker>\n      <MarkerContent>\n        {scrollable.start || scrollable.end\n          ? \"More transcript content is available.\"\n          : \"All messages fit in the viewport.\"}\n      </MarkerContent>\n    </Marker>\n  );\n}\n\n/**\n * Read scroll state for custom status text or controls.\n */\nexport const ScrollState: Story = {\n  render: () => (\n    <MessageScrollerProvider>\n      <div className=\"flex size-full min-h-0 flex-col overflow-hidden\">\n        <div className=\"shrink-0 border-b p-2\">\n          <ScrollStatus />\n        </div>\n        <div className=\"min-h-0 flex-1 overflow-hidden\">\n          <Transcript />\n        </div>\n      </div>\n    </MessageScrollerProvider>\n  ),\n};\n\n/**\n * Verify external controls can scroll to the latest transcript turn.\n */\nexport const ScrollToLatest: Story = {\n  tags: [\"!dev\", \"!autodocs\"],\n  render: () => (\n    <MessageScrollerProvider>\n      <div className=\"flex size-full min-h-0 flex-col overflow-hidden\">\n        <JumpControls />\n        <div className=\"min-h-0 flex-1 overflow-hidden\">\n          <Transcript />\n        </div>\n      </div>\n    </MessageScrollerProvider>\n  ),\n  play: async ({ canvasElement }) => {\n    const canvas = within(canvasElement);\n    const endButton = canvas.getByRole(\"button\", { name: \"End\" });\n\n    await userEvent.click(endButton);\n    expect(endButton).toHaveFocus();\n  },\n};\n\n/**\n * Verify loading history preserves the visible transcript position.\n */\nexport const PrependHistoryPreservesPosition: Story = {\n  tags: [\"!dev\", \"!autodocs\"],\n  render: LoadHistory.render,\n  play: async ({ canvasElement }) => {\n    const canvas = within(canvasElement);\n\n    await userEvent.click(\n      canvas.getByRole(\"button\", { name: /load history/i }),\n    );\n    expect(canvas.getByText(/billing changes/i)).toBeInTheDocument();\n  },\n};\n",
      "type": "registry:component",
      "target": "@ui/message-scroller.stories.tsx"
    }
  ],
  "categories": [
    "ui",
    "storybook",
    "message-scroller",
    "chat"
  ],
  "type": "registry:component"
}