Conversation
Chat viewport built on the message scroller: follows streaming replies, empty and home states, scroll-to-latest and transcript download.
import { Conversation, ConversationContent, ConversationDownload, ConversationItem, ConversationScrollButton,} from "@/components/ai/conversation";import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";import { Shimmer } from "@/components/ai/shimmer";import { IconPlaceholder } from "@/edmi/icon-placeholder";
const turns = [ { role: "user", text: "Which megacaps drift the most this month?" }, { role: "assistant", text: "NVDAx moved most: **+2.4%** over its 32% target. MSFTx and AAPLx are within 0.5%.", }, { role: "user", text: "Rebalance if drift is above 2%." },] as const;
export default function Demo() { return ( <div className="relative flex h-96 w-full max-w-xl overflow-hidden rounded-xl border border-border bg-card"> <Conversation className="size-full"> <ConversationContent className="pt-14"> {turns.map((t, i) => ( <ConversationItem key={t.text} messageId={`m${i}`}> <Message from={t.role}> <MessageContent> {t.role === "assistant" ? ( <MessageResponse>{t.text}</MessageResponse> ) : ( t.text )} </MessageContent> {t.role === "assistant" && ( <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"> <IconPlaceholder lucide="CopyIcon" tabler="IconCopy" hugeicons="Copy01Icon" phosphor="CopyIcon" remixicon="RiFileCopyLine" /> </MessageAction> </MessageActions> </MessageToolbar> )} </Message> </ConversationItem> ))} <ConversationItem messageId="status"> <Message from="assistant"> <MessageContent> <Shimmer>Checking keeper limits...</Shimmer> </MessageContent> </Message> </ConversationItem> </ConversationContent> <ConversationScrollButton /> <ConversationDownload messages={turns.map((t, i) => ({ id: `m${i}`, role: t.role, parts: [{ type: "text" as const, text: t.text }], }))} /> </Conversation> </div> );}<script setup lang="ts">import { CopyIcon } from "@lucide/vue";import { Conversation, ConversationContent, ConversationDownload, ConversationItem, ConversationScrollButton,} from "@/components/ai/conversation";import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";import { Shimmer } from "@/components/ai/shimmer";
const turns = [ { role: "user", text: "Which megacaps drift the most this month?" }, { role: "assistant", text: "NVDAx moved most: **+2.4%** over its 32% target. MSFTx and AAPLx are within 0.5%.", }, { role: "user", text: "Rebalance if drift is above 2%." },] as const;
const messages = turns.map((t, i) => ({ id: `m${i}`, role: t.role, parts: [{ type: "text" as const, text: t.text }],}));</script>
<template> <div class="relative flex h-96 w-full max-w-xl overflow-hidden rounded-xl border border-border bg-card"> <Conversation class="size-full"> <ConversationContent class="pt-14"> <ConversationItem v-for="(t, i) in turns" :key="t.text" :message-id="`m${i}`"> <Message :from="t.role"> <MessageContent> <MessageResponse v-if="t.role === 'assistant'" :content="t.text" /> <template v-else>{{ t.text }}</template> </MessageContent> <MessageToolbar v-if="t.role === 'assistant'"> <MessageActions> <MessageAction tooltip="Copy"> <CopyIcon /> </MessageAction> </MessageActions> </MessageToolbar> </Message> </ConversationItem> <ConversationItem message-id="status"> <Message from="assistant"> <MessageContent> <Shimmer>Checking keeper limits...</Shimmer> </MessageContent> </Message> </ConversationItem> </ConversationContent> <ConversationScrollButton /> <ConversationDownload :messages="messages" /> </Conversation> </div></template><script lang="ts"> import { Conversation, ConversationContent, ConversationDownload, ConversationItem, ConversationScrollButton, } from "$lib/components/ai/conversation"; import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar, } from "$lib/components/ai/message"; import { Shimmer } from "$lib/components/ai/shimmer"; import IconPlaceholder from "$lib/components/icon-placeholder/icon-placeholder.svelte";
const turns = [ { role: "user", text: "Which megacaps drift the most this month?" }, { role: "assistant", text: "NVDAx moved most: **+2.4%** over its 32% target. MSFTx and AAPLx are within 0.5%.", }, { role: "user", text: "Rebalance if drift is above 2%." }, ] as const;
const messages = turns.map((t, i) => ({ id: `m${i}`, role: t.role, parts: [{ type: "text" as const, text: t.text }], }));</script>
<div class="relative flex h-96 w-full max-w-xl overflow-hidden rounded-xl border border-border bg-card"> <Conversation class="size-full"> <ConversationContent class="pt-14"> {#each turns as t, i (t.text)} <ConversationItem messageId={`m${i}`}> <Message from={t.role}> <MessageContent> {#if t.role === "assistant"} <MessageResponse content={t.text} /> {:else} {t.text} {/if} </MessageContent> {#if t.role === "assistant"} <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"> <IconPlaceholder lucide="CopyIcon" tabler="IconCopy" hugeicons="Copy01Icon" phosphor="CopyIcon" remixicon="RiFileCopyLine" /> </MessageAction> </MessageActions> </MessageToolbar> {/if} </Message> </ConversationItem> {/each} <ConversationItem messageId="status"> <Message from="assistant"> <MessageContent> <Shimmer>Checking keeper limits...</Shimmer> </MessageContent> </Message> </ConversationItem> </ConversationContent> <ConversationScrollButton /> <ConversationDownload {messages} /> </Conversation></div>Built on message-scroller (@edmi-ui/message-scroller).
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-conversationpnpm dlx shadcn@latest add @edmi-ui/ai-conversationyarn dlx shadcn@latest add @edmi-ui/ai-conversationbunx shadcn@latest add @edmi-ui/ai-conversationPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install aiTerminal window pnpm add aiTerminal window yarn add aiTerminal window bun add aiAdd the Edmi components this one builds on:
button,message-scroller.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-conversationpnpm dlx shadcn-vue@latest add @edmi-ui/ai-conversationyarn dlx shadcn-vue@latest add @edmi-ui/ai-conversationbunx shadcn-vue@latest add @edmi-ui/ai-conversationPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install ai @lucide/vueTerminal window pnpm add ai @lucide/vueTerminal window yarn add ai @lucide/vueTerminal window bun add ai @lucide/vueAdd the Edmi components this one builds on:
button,message-scroller.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,message-scroller.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Conversation, ConversationContent, ConversationDownload, ConversationItem, ConversationScrollButton,} from "@/components/ai/conversation";import { Message, MessageContent } from "@/components/ai/message";import { Shimmer } from "@/components/ai/shimmer";
<div className="relative flex h-96 w-full max-w-xl overflow-hidden rounded-xl border border-border bg-card"> <Conversation className="size-full"> <ConversationContent className="pt-14"> <ConversationItem messageId="status"> <Message from="assistant"> <MessageContent> <Shimmer>Checking keeper limits...</Shimmer> </MessageContent> </Message> </ConversationItem> </ConversationContent> <ConversationScrollButton /> <ConversationDownload /> </Conversation></div><script setup lang="ts">import { Conversation, ConversationContent, ConversationDownload, ConversationItem, ConversationScrollButton,} from "@/components/ai/conversation";import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";</script>
<template> <div class="relative flex h-96 w-full max-w-xl overflow-hidden rounded-xl border border-border bg-card"> <Conversation class="size-full"> <ConversationContent class="pt-14"> <ConversationItem :key="t.text" :message-id="`m${i}`"> <Message :from="t.role"> <MessageContent> <MessageResponse v-if="t.role === 'assistant'" :content="t.text" /> <template v-else>{{ t.text }}</template> </MessageContent> <MessageToolbar v-if="t.role === 'assistant'"> <MessageActions> <MessageAction tooltip="Copy"> <CopyIcon /> </MessageAction> </MessageActions> </MessageToolbar> </Message> </ConversationItem> <!-- … --> </ConversationContent> <ConversationScrollButton /> <ConversationDownload /> </Conversation> </div></template><script lang="ts"> import { Conversation, ConversationContent, ConversationItem, } from "$lib/components/ai/conversation"; import { Message, MessageActions, MessageContent, MessageResponse, MessageToolbar, } from "$lib/components/ai/message";</script>
<div class="relative flex h-96 w-full max-w-xl overflow-hidden rounded-xl border border-border bg-card"> <Conversation class="size-full"> <ConversationContent class="pt-14"> {#each turns as t, i (t.text)} <ConversationItem messageId={`m${i}`}> <Message from={t.role}> <MessageContent> {#if t.role === "assistant"} <MessageResponse content={t.text} /> {:else} {t.text} {/if} </MessageContent> {#if t.role === "assistant"} <MessageToolbar> <MessageActions> <!-- … --> </MessageActions> </MessageToolbar> {/if} </Message> </ConversationItem> {/each} <!-- … --> </ConversationContent> <!-- … --> </Conversation></div>Component source
Section titled “Component source”"use client";
import type { UIMessage } from "ai";import { cn } from "cn";import type { ComponentProps, ReactNode } from "react";import { useCallback } from "react";import { ArrowDownIcon, DownloadIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/registry/edmi/ui/message-scroller";
// Thin layer on ui/message-scroller (DESIGN §5b): the scroller already follows streaming replies and// owns the scroll state; this adds the AI Elements anatomy, EmptyState (+ home variant) and Download.
export type ConversationProps = ComponentProps<typeof MessageScroller> & { /** Follow new content while the user is at the bottom. */ autoScroll?: boolean;};
export const Conversation = ({ className, autoScroll = true, ...props}: ConversationProps) => ( <MessageScrollerProvider autoScroll={autoScroll}> <MessageScroller data-slot="ai-conversation" role="log" className={cn("flex-1", className)} {...props} /> </MessageScrollerProvider>);
export type ConversationContentProps = ComponentProps< typeof MessageScrollerContent>;
/** Viewport + content column. Wrap each turn in `ConversationItem` so the scroller can anchor it. */export const ConversationContent = ({ className, ...props}: ConversationContentProps) => ( <MessageScrollerViewport> <MessageScrollerContent data-slot="ai-conversation-content" className={cn("gap-6 p-4", className)} {...props} /> </MessageScrollerViewport>);
export type ConversationItemProps = ComponentProps<typeof MessageScrollerItem>;
/** One turn inside the conversation (pass `messageId`; `scrollAnchor` pins a user turn to the top). */export const ConversationItem = (props: ConversationItemProps) => ( <MessageScrollerItem data-slot="ai-conversation-item" {...props} />);
export type ConversationEmptyStateProps = Omit< ComponentProps<"div">, "title"> & { title?: ReactNode; description?: ReactNode; icon?: ReactNode; /** * `default`: icon tile, title and description centered. * `home` ✦: greeting heading and subtitle on the empty chat; put the composer and suggestions in `children`. */ variant?: "default" | "home";};
export const ConversationEmptyState = ({ className, title = "No messages yet", description = "Start a conversation to see messages here", icon, variant = "default", children, ...props}: ConversationEmptyStateProps) => ( <div data-slot="ai-conversation-empty" data-variant={variant} className={cn( "flex size-full flex-col items-center justify-center gap-3 p-8 text-center", variant === "home" && "gap-6", className, )} {...props} > {variant === "home" ? ( <> <div className="flex flex-col items-center gap-2"> {icon && <div className="text-muted-foreground">{icon}</div>} <h2 className="text-[28px] leading-tight font-normal tracking-[-0.6px] text-foreground-2"> {title} </h2> {description && ( <p className="text-[15px] text-muted-foreground">{description}</p> )} </div> {children} </> ) : ( (children ?? ( <> {icon && ( <div data-slot="ai-conversation-empty-icon" className="flex size-12 items-center justify-center rounded-xl border border-border bg-card text-foreground [&_svg:not([class*='size-'])]:size-5" > {icon} </div> )} <div className="space-y-1"> <h3 className="text-sm font-semibold">{title}</h3> {description && ( <p className="text-[13px] text-muted-foreground">{description}</p> )} </div> </> )) )} </div>);
export type ConversationScrollButtonProps = ComponentProps< typeof MessageScrollerButton>;
/** Round "scroll to latest" button; only visible when the user scrolled up. */export const ConversationScrollButton = ({ className, children, size = "icon", ...props}: ConversationScrollButtonProps) => ( <MessageScrollerButton aria-label="Scroll to latest" size={size} className={cn("rounded-full", className)} {...props} > {children ?? ( <ArrowDownIcon className="size-4" /> )} </MessageScrollerButton>);
const getMessageText = (message: UIMessage): string => message.parts .filter((part) => part.type === "text") .map((part) => part.text) .join("");
export type ConversationDownloadProps = Omit< ComponentProps<typeof Button>, "onClick"> & { messages: UIMessage[]; filename?: string; formatMessage?: (message: UIMessage, index: number) => string;};
const defaultFormatMessage = (message: UIMessage): string => { const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1); return `**${roleLabel}:** ${getMessageText(message)}`;};
export const messagesToMarkdown = ( messages: UIMessage[], formatMessage: ( message: UIMessage, index: number, ) => string = defaultFormatMessage,): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({ messages, filename = "conversation.md", formatMessage = defaultFormatMessage, className, children, ...props}: ConversationDownloadProps) => { const handleDownload = useCallback(() => { const markdown = messagesToMarkdown(messages, formatMessage); const blob = new Blob([markdown], { type: "text/markdown" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); }, [messages, filename, formatMessage]);
return ( <Button data-slot="ai-conversation-download" aria-label="Download conversation" className={cn("absolute top-4 right-4 rounded-full", className)} onClick={handleDownload} size="icon" type="button" variant="outline" {...props} > {children ?? ( <DownloadIcon className="size-4" /> )} </Button> );};<script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { MessageScroller, MessageScrollerProvider } from "@/registry/edmi/ui/message-scroller"
// Thin layer on ui/message-scroller (DESIGN §5b): the scroller already follows streaming replies and// owns the scroll state; this adds the AI Elements anatomy, EmptyState (+ home variant) and Download.
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] /** Follow new content while the user is at the bottom. */ autoScroll?: boolean}>(), { autoScroll: true,})</script>
<template> <MessageScrollerProvider :auto-scroll="props.autoScroll"> <MessageScroller v-bind="$attrs" data-slot="ai-conversation" role="log" :class="cn('flex-1', props.class)" > <slot /> </MessageScroller> </MessageScrollerProvider></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { MessageScrollerContent, MessageScrollerViewport } from "@/registry/edmi/ui/message-scroller"
defineOptions({ inheritAttrs: false })
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <!-- Viewport + content column. Wrap each turn in ConversationItem so the scroller can anchor it. --> <MessageScrollerViewport> <MessageScrollerContent v-bind="$attrs" data-slot="ai-conversation-content" :class="cn('gap-6 p-4', props.class)" > <slot /> </MessageScrollerContent> </MessageScrollerViewport></template><script setup lang="ts">import type { UIMessage } from "ai"import type { HTMLAttributes } from "vue"import { DownloadIcon } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"import { defaultFormatMessage, messagesToMarkdown } from "./utils"
const props = withDefaults(defineProps<{ messages: UIMessage[] filename?: string formatMessage?: (message: UIMessage, index: number) => string class?: HTMLAttributes["class"]}>(), { filename: "conversation.md", formatMessage: defaultFormatMessage,})
function handleDownload() { const markdown = messagesToMarkdown(props.messages, props.formatMessage) const blob = new Blob([markdown], { type: "text/markdown" }) const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = props.filename document.body.append(link) link.click() link.remove() URL.revokeObjectURL(url)}</script>
<template> <Button data-slot="ai-conversation-download" aria-label="Download conversation" :class="cn('absolute top-4 right-4 rounded-full', props.class)" size="icon" type="button" variant="outline" @click="handleDownload" > <slot> <DownloadIcon class="size-4" /> </slot> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] title?: string description?: string /** * `default`: icon tile, title and description centered. * `home` ✦: greeting heading and subtitle on the empty chat; put the composer and suggestions in the default slot. */ variant?: "default" | "home"}>(), { title: "No messages yet", description: "Start a conversation to see messages here", variant: "default",})</script>
<template> <div data-slot="ai-conversation-empty" :data-variant="props.variant" :class="cn( 'flex size-full flex-col items-center justify-center gap-3 p-8 text-center', props.variant === 'home' && 'gap-6', props.class, )" > <template v-if="props.variant === 'home'"> <div class="flex flex-col items-center gap-2"> <div v-if="$slots.icon" class="text-muted-foreground"> <slot name="icon" /> </div> <h2 class="text-[28px] leading-tight font-normal tracking-[-0.6px] text-foreground-2"> {{ props.title }} </h2> <p v-if="props.description" class="text-[15px] text-muted-foreground"> {{ props.description }} </p> </div> <slot /> </template> <slot v-else> <div v-if="$slots.icon" data-slot="ai-conversation-empty-icon" class="flex size-12 items-center justify-center rounded-xl border border-border bg-card text-foreground [&_svg:not([class*='size-'])]:size-5" > <slot name="icon" /> </div> <div class="space-y-1"> <h3 class="text-sm font-semibold"> {{ props.title }} </h3> <p v-if="props.description" class="text-[13px] text-muted-foreground"> {{ props.description }} </p> </div> </slot> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { MessageScrollerItem } from "@/registry/edmi/ui/message-scroller"
// One turn inside the conversation (pass `messageId`; `scrollAnchor` pins a user turn to the top).const props = defineProps<{ messageId?: string scrollAnchor?: boolean class?: HTMLAttributes["class"]}>()</script>
<template> <MessageScrollerItem data-slot="ai-conversation-item" :message-id="props.messageId" :scroll-anchor="props.scrollAnchor" :class="props.class" > <slot /> </MessageScrollerItem></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { ArrowDownIcon } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"import { MessageScrollerButton } from "@/registry/edmi/ui/message-scroller"
// Round "scroll to latest" button; only visible when the user scrolled up.const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] size?: ButtonVariants["size"]}>(), { size: "icon",})</script>
<template> <MessageScrollerButton aria-label="Scroll to latest" :size="props.size" :class="cn('rounded-full', props.class)" > <slot> <ArrowDownIcon class="size-4" /> </slot> </MessageScrollerButton></template>import type { UIMessage } from "ai";
export function getMessageText(message: UIMessage): string { return message.parts .filter((part) => part.type === "text") .map((part) => part.text) .join("");}
export function defaultFormatMessage(message: UIMessage): string { const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1); return `**${roleLabel}:** ${getMessageText(message)}`;}
export function messagesToMarkdown( messages: UIMessage[], formatMessage: ( message: UIMessage, index: number, ) => string = defaultFormatMessage,): string { return messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");}export { default as Conversation } from "./Conversation.vue";export { default as ConversationContent } from "./ConversationContent.vue";export { default as ConversationDownload } from "./ConversationDownload.vue";export { default as ConversationEmptyState } from "./ConversationEmptyState.vue";export { default as ConversationItem } from "./ConversationItem.vue";export { default as ConversationScrollButton } from "./ConversationScrollButton.vue";export * from "./utils";<script lang="ts"> import { MessageScrollerContent, MessageScrollerViewport, } from "$lib/registry/ui/message-scroller/index.js"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<!-- Viewport + content column. Wrap each turn in ConversationItem so the scroller can anchor it. --><MessageScrollerViewport> <MessageScrollerContent data-slot="ai-conversation-content" class={cn("gap-6 p-4", className)} {...restProps} > {@render children?.()} </MessageScrollerContent></MessageScrollerViewport><script lang="ts"> import DownloadIcon from "phosphor-svelte/lib/Download"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { cn } from "$lib/utils.js"; import type { UIMessage } from "ai"; import { defaultFormatMessage, messagesToMarkdown } from "./utils.js";
let { messages, filename = "conversation.md", formatMessage = defaultFormatMessage, class: className, children, ...restProps }: Omit<ButtonProps, "onclick" | "href"> & { messages: UIMessage[]; filename?: string; formatMessage?: (message: UIMessage, index: number) => string; } = $props();
function handleDownload() { const markdown = messagesToMarkdown(messages, formatMessage); const blob = new Blob([markdown], { type: "text/markdown" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); }</script>
<Button data-slot="ai-conversation-download" aria-label="Download conversation" class={cn("absolute top-4 right-4 rounded-full", className)} onclick={handleDownload} size="icon" type="button" variant="outline" {...restProps}> {#if children} {@render children()} {:else} <DownloadIcon class="size-4" /> {/if}</Button><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, title = "No messages yet", description = "Start a conversation to see messages here", icon, variant = "default", children, ...restProps }: Omit<HTMLAttributes<HTMLDivElement>, "title"> & { title?: string; description?: string; icon?: Snippet; /** * `default`: icon tile, title and description centered. * `home` ✦: greeting heading and subtitle on the empty chat; put the composer and suggestions in `children`. */ variant?: "default" | "home"; children?: Snippet; } = $props();</script>
<div data-slot="ai-conversation-empty" data-variant={variant} class={cn( "flex size-full flex-col items-center justify-center gap-3 p-8 text-center", variant === "home" && "gap-6", className )} {...restProps}> {#if variant === "home"} <div class="flex flex-col items-center gap-2"> {#if icon} <div class="text-muted-foreground">{@render icon()}</div> {/if} <h2 class="text-[28px] leading-tight font-normal tracking-[-0.6px] text-foreground-2"> {title} </h2> {#if description} <p class="text-[15px] text-muted-foreground">{description}</p> {/if} </div> {@render children?.()} {:else if children} {@render children()} {:else} {#if icon} <div data-slot="ai-conversation-empty-icon" class="flex size-12 items-center justify-center rounded-xl border border-border bg-card text-foreground [&_svg:not([class*='size-'])]:size-5" > {@render icon()} </div> {/if} <div class="space-y-1"> <h3 class="text-sm font-semibold">{title}</h3> {#if description} <p class="text-[13px] text-muted-foreground">{description}</p> {/if} </div> {/if}</div><script lang="ts"> import { MessageScrollerItem } from "$lib/registry/ui/message-scroller/index.js"; import type { ComponentProps } from "svelte";
// One turn inside the conversation (pass `messageId`; `scrollAnchor` pins a user turn to the top). let { children, ...restProps }: ComponentProps<typeof MessageScrollerItem> = $props();</script>
<MessageScrollerItem data-slot="ai-conversation-item" {...restProps}> {@render children?.()}</MessageScrollerItem><script lang="ts"> import ArrowDownIcon from "phosphor-svelte/lib/ArrowDown"; import { MessageScrollerButton } from "$lib/registry/ui/message-scroller/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
// Round "scroll to latest" button; only visible when the user scrolled up. let { class: className, size = "icon", children, ...restProps }: ComponentProps<typeof MessageScrollerButton> = $props();</script>
<MessageScrollerButton aria-label="Scroll to latest" {size} class={cn("rounded-full", className)} {...restProps}> {#if children} {@render children()} {:else} <ArrowDownIcon class="size-4" /> {/if}</MessageScrollerButton><script lang="ts"> import { MessageScroller, MessageScrollerProvider, } from "$lib/registry/ui/message-scroller/index.js"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
// Thin layer on ui/message-scroller (DESIGN §5b): the scroller already follows streaming replies and // owns the scroll state; this adds the AI Elements anatomy, EmptyState (+ home variant) and Download.
let { class: className, autoScroll = true, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { /** Follow new content while the user is at the bottom. */ autoScroll?: boolean; children?: Snippet; } = $props();</script>
<MessageScrollerProvider {autoScroll}> <MessageScroller data-slot="ai-conversation" role="log" class={cn("flex-1", className)} {...restProps} > {@render children?.()} </MessageScroller></MessageScrollerProvider>import Root from "./conversation.svelte";import Content from "./conversation-content.svelte";import Download from "./conversation-download.svelte";import EmptyState from "./conversation-empty-state.svelte";import Item from "./conversation-item.svelte";import ScrollButton from "./conversation-scroll-button.svelte";
export * from "./utils.js";export { Content, Content as ConversationContent, Download, Download as ConversationDownload, EmptyState, EmptyState as ConversationEmptyState, Item, Item as ConversationItem, Root, // Root as Conversation, ScrollButton, ScrollButton as ConversationScrollButton,};import type { UIMessage } from "ai";
export function getMessageText(message: UIMessage): string { return message.parts .filter((part) => part.type === "text") .map((part) => part.text) .join("");}
export function defaultFormatMessage(message: UIMessage): string { const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1); return `**${roleLabel}:** ${getMessageText(message)}`;}
export function messagesToMarkdown( messages: UIMessage[], formatMessage: ( message: UIMessage, index: number, ) => string = defaultFormatMessage,): string { return messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");}More examples
Section titled “More examples”Empty and home states
Section titled “Empty and home states”Start a conversation
Ask about an index, a token or your portfolio.
Good evening, Dwi
What should the keeper look at?
Start a conversation
Ask about an index, a token or your portfolio.
Good evening, Dwi
What should the keeper look at?
Start a conversation
Ask about an index, a token or your portfolio.
Good evening, Dwi
What should the keeper look at?
import { ConversationEmptyState } from "@/components/ai/conversation";import { Suggestion } from "@/components/ai/suggestion";import { IconPlaceholder } from "@/edmi/icon-placeholder";
export default function Demo() { return ( <div className="grid w-full max-w-3xl gap-4 sm:grid-cols-2"> <div className="flex h-72 rounded-xl border border-border bg-card"> <ConversationEmptyState title="Start a conversation" description="Ask about an index, a token or your portfolio." icon={ <IconPlaceholder lucide="MessageSquareIcon" tabler="IconMessage" hugeicons="Message01Icon" phosphor="ChatIcon" remixicon="RiChat1Line" /> } /> </div> <div className="flex h-72 rounded-xl border border-border bg-card"> <ConversationEmptyState variant="home" title="Good evening, Dwi" description="What should the keeper look at?" > <div className="flex flex-wrap justify-center gap-2"> <Suggestion suggestion="Check drift" /> <Suggestion suggestion="Show my fees" /> </div> </ConversationEmptyState> </div> </div> );}<script setup lang="ts">import { MessageSquareIcon } from "@lucide/vue";import { ConversationEmptyState } from "@/components/ai/conversation";import { Suggestion } from "@/components/ai/suggestion";</script>
<template> <div class="grid w-full max-w-3xl gap-4 sm:grid-cols-2"> <div class="flex h-72 rounded-xl border border-border bg-card"> <ConversationEmptyState title="Start a conversation" description="Ask about an index, a token or your portfolio." > <template #icon> <MessageSquareIcon /> </template> </ConversationEmptyState> </div> <div class="flex h-72 rounded-xl border border-border bg-card"> <ConversationEmptyState variant="home" title="Good evening, Dwi" description="What should the keeper look at?" > <div class="flex flex-wrap justify-center gap-2"> <Suggestion suggestion="Check drift" /> <Suggestion suggestion="Show my fees" /> </div> </ConversationEmptyState> </div> </div></template><script lang="ts"> import { ConversationEmptyState } from "$lib/components/ai/conversation"; import { Suggestion } from "$lib/components/ai/suggestion"; import IconPlaceholder from "$lib/components/icon-placeholder/icon-placeholder.svelte";</script>
<div class="grid w-full max-w-3xl gap-4 sm:grid-cols-2"> <div class="flex h-72 rounded-xl border border-border bg-card"> <ConversationEmptyState title="Start a conversation" description="Ask about an index, a token or your portfolio." > {#snippet icon()} <IconPlaceholder lucide="MessageSquareIcon" tabler="IconMessage" hugeicons="Message01Icon" phosphor="ChatIcon" remixicon="RiChat1Line" /> {/snippet} </ConversationEmptyState> </div> <div class="flex h-72 rounded-xl border border-border bg-card"> <ConversationEmptyState variant="home" title="Good evening, Dwi" description="What should the keeper look at?" > <div class="flex flex-wrap justify-center gap-2"> <Suggestion suggestion="Check drift" /> <Suggestion suggestion="Show my fees" /> </div> </ConversationEmptyState> </div></div>| Part | Prop | Type | Default |
|---|---|---|---|
| Conversation | autoScroll |
boolean |
true |
| ConversationContent | MessageScrollerContent props |
||
| ConversationItem | messageId, scrollAnchor |
string, boolean |
|
| ConversationEmptyState | title, description, icon, variant |
ReactNode, default | home |
default |
| ConversationScrollButton | MessageScrollerButton props |
||
| ConversationDownload | messages, filename, formatMessage |
UIMessage[], string, fn |
conversation.md |
Wrap each turn in ConversationItem so the scroller can anchor it; the container needs a height.
Vue and Svelte
Section titled “Vue and Svelte”ConversationEmptyStateicon: Reacticonprop, Vue#iconslot, Svelteiconsnippet.
✦ Edmi additions
Section titled “✦ Edmi additions”ConversationEmptyState variant="home": greeting heading and subtitle with achildrenslot for the composer and suggestions.- The scroll button is a round outline icon button; the stick-to-bottom logic comes from
message-scroller, notuse-stick-to-bottom.