Skip to content
edmi-ui

Conversation

Chat viewport built on the message scroller: follows streaming replies, empty and home states, scroll-to-latest and transcript download.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Which megacaps drift the most this month?

NVDAx moved most: +2.4% over its 32% target. MSFTx and AAPLx are within 0.5%.

Rebalance if drift is above 2%.

Checking keeper limits...

Which megacaps drift the most this month?
Rebalance if drift is above 2%.

Checking keeper limits...

Which megacaps drift the most this month?

NVDAx moved most: +2.4% over its 32% target. MSFTx and AAPLx are within 0.5%.

Rebalance if drift is above 2%.

Checking keeper limits...

Built on message-scroller (@edmi-ui/message-scroller).

Terminal window
npx shadcn@latest add @edmi-ui/ai-conversation
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-conversation
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-conversation
Terminal window
bunx shadcn@latest add @edmi-ui/ai-conversation

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-conversation
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-conversation
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-conversation
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-conversation

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-conversation.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/conversation.tsx
"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>
);
};
registry/components/ai/conversation/Conversation.vue
<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>
registry/components/ai/conversation/ConversationContent.vue
<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>
registry/components/ai/conversation/ConversationDownload.vue
<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>
registry/components/ai/conversation/ConversationEmptyState.vue
<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>
registry/components/ai/conversation/ConversationItem.vue
<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>
registry/components/ai/conversation/ConversationScrollButton.vue
<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>
registry/components/ai/conversation/utils.ts
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");
}
registry/components/ai/conversation/index.ts
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";
src/lib/registry/ai/conversation/conversation-content.svelte
<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>
src/lib/registry/ai/conversation/conversation-download.svelte
<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>
src/lib/registry/ai/conversation/conversation-empty-state.svelte
<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>
src/lib/registry/ai/conversation/conversation-item.svelte
<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>
src/lib/registry/ai/conversation/conversation-scroll-button.svelte
<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>
src/lib/registry/ai/conversation/conversation.svelte
<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>
src/lib/registry/ai/conversation/index.ts
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,
};
src/lib/registry/ai/conversation/utils.ts
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");
}
StackBlitz ↗StackBlitz ↗StackBlitz ↗

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?

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.

  • ConversationEmptyState icon: React icon prop, Vue #icon slot, Svelte icon snippet.
  • ConversationEmptyState variant="home": greeting heading and subtitle with a children slot for the composer and suggestions.
  • The scroll button is a round outline icon button; the stick-to-bottom logic comes from message-scroller, not use-stick-to-bottom.