Message
One chat turn: secondary user bubble or full-width assistant response with markdown typography, actions, branches and toolbar.
import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";import { IconPlaceholder } from "@/edmi/icon-placeholder";
const markdown = `### Rebalance plan
1. Sell **0.42 NVDAx** ($79)2. Buy **0.18 MSFTx** and **0.21 AAPLx**
Estimated slippage \`0.08%\`.
> Slippage stays under 1% at current depth.
Sources: [Jupiter quote API](https://example.com).`;
export default function Demo() { return ( <div className="flex w-full max-w-xl flex-col gap-6"> <Message from="user"> <MessageContent>Rebalance if drift is above 2%.</MessageContent> </Message> <Message from="assistant"> <MessageContent> <MessageResponse>{markdown}</MessageResponse> </MessageContent> <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"> <IconPlaceholder lucide="CopyIcon" tabler="IconCopy" hugeicons="Copy01Icon" phosphor="CopyIcon" remixicon="RiFileCopyLine" /> </MessageAction> <MessageAction tooltip="Regenerate"> <IconPlaceholder lucide="RefreshCcwIcon" tabler="IconRefresh" hugeicons="ReloadIcon" phosphor="ArrowClockwiseIcon" remixicon="RiRefreshLine" /> </MessageAction> <MessageAction tooltip="Good response"> <IconPlaceholder lucide="ThumbsUpIcon" tabler="IconThumbUp" hugeicons="ThumbsUpIcon" phosphor="ThumbsUpIcon" remixicon="RiThumbUpLine" /> </MessageAction> </MessageActions> </MessageToolbar> </Message> </div> );}<script setup lang="ts">import { CopyIcon, RefreshCwIcon, CircleCheckIcon } from "@lucide/vue";import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";
const markdown = `### Rebalance plan
1. Sell **0.42 NVDAx** ($79)2. Buy **0.18 MSFTx** and **0.21 AAPLx**
Estimated slippage \`0.08%\`.
> Slippage stays under 1% at current depth.
Sources: [Jupiter quote API](https://example.com).`;</script>
<template> <div class="flex w-full max-w-xl flex-col gap-6"> <Message from="user"> <MessageContent>Rebalance if drift is above 2%.</MessageContent> </Message> <Message from="assistant"> <MessageContent> <MessageResponse :content="markdown" /> </MessageContent> <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"><CopyIcon /></MessageAction> <MessageAction tooltip="Regenerate"><RefreshCwIcon /></MessageAction> <MessageAction tooltip="Good response"><CircleCheckIcon /></MessageAction> </MessageActions> </MessageToolbar> </Message> </div></template><script lang="ts"> import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar, } from "$lib/components/ai/message"; import IconPlaceholder from "$lib/components/icon-placeholder/icon-placeholder.svelte";
const markdown = `### Rebalance plan
1. Sell **0.42 NVDAx** ($79)2. Buy **0.18 MSFTx** and **0.21 AAPLx**
Estimated slippage \`0.08%\`.
> Slippage stays under 1% at current depth.
Sources: [Jupiter quote API](https://example.com).`;</script>
<div class="flex w-full max-w-xl flex-col gap-6"> <Message from="user"> <MessageContent>Rebalance if drift is above 2%.</MessageContent> </Message> <Message from="assistant"> <MessageContent> <MessageResponse content={markdown} /> </MessageContent> <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"> <IconPlaceholder lucide="CopyIcon" tabler="IconCopy" hugeicons="Copy01Icon" phosphor="CopyIcon" remixicon="RiFileCopyLine" /> </MessageAction> <MessageAction tooltip="Regenerate"> <IconPlaceholder lucide="RefreshCcwIcon" tabler="IconRefresh" hugeicons="ReloadIcon" phosphor="ArrowClockwiseIcon" remixicon="RiRefreshLine" /> </MessageAction> <MessageAction tooltip="Good response"> <IconPlaceholder lucide="ThumbsUpIcon" tabler="IconThumbUp" hugeicons="ThumbsUpIcon" phosphor="ThumbsUpIcon" remixicon="RiThumbUpLine" /> </MessageAction> </MessageActions> </MessageToolbar> </Message></div>Built on message and bubble.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-messagepnpm dlx shadcn@latest add @edmi-ui/ai-messageyarn dlx shadcn@latest add @edmi-ui/ai-messagebunx shadcn@latest add @edmi-ui/ai-messagePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid ai streamdownTerminal window pnpm add @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid ai streamdownTerminal window yarn add @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid ai streamdownTerminal window bun add @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid ai streamdownAdd the Edmi components this one builds on:
button,button-group,tooltip,message,bubble.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-messagepnpm dlx shadcn-vue@latest add @edmi-ui/ai-messageyarn dlx shadcn-vue@latest add @edmi-ui/ai-messagebunx shadcn-vue@latest add @edmi-ui/ai-messagePick 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/vue vue-stream-markdownTerminal window pnpm add ai @lucide/vue vue-stream-markdownTerminal window yarn add ai @lucide/vue vue-stream-markdownTerminal window bun add ai @lucide/vue vue-stream-markdownAdd the Edmi components this one builds on:
button,button-group,tooltip,message,bubble.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-message.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-message.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-message.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-message.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,button-group,tooltip,message,bubble.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";
<div className="flex w-full max-w-xl flex-col gap-6"> <Message from="user"> <MessageContent>Rebalance if drift is above 2%.</MessageContent> </Message> <Message from="assistant"> <MessageContent> <MessageResponse></MessageResponse> </MessageContent> <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"> <CopyIcon /> </MessageAction> <MessageAction tooltip="Regenerate"> <ArrowClockwiseIcon /> </MessageAction> <MessageAction tooltip="Good response"> <ThumbsUpIcon /> </MessageAction> </MessageActions> </MessageToolbar> </Message></div><script setup lang="ts">import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";</script>
<template> <div class="flex w-full max-w-xl flex-col gap-6"> <Message from="user"> <MessageContent>Rebalance if drift is above 2%.</MessageContent> </Message> <Message from="assistant"> <MessageContent> <MessageResponse /> </MessageContent> <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"><CopyIcon /></MessageAction> <MessageAction tooltip="Regenerate"><RefreshCwIcon /></MessageAction> <MessageAction tooltip="Good response"><CircleCheckIcon /></MessageAction> </MessageActions> </MessageToolbar> </Message> </div></template><script lang="ts"> import { Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar, } from "$lib/components/ai/message";</script>
<div class="flex w-full max-w-xl flex-col gap-6"> <Message from="user"> <MessageContent>Rebalance if drift is above 2%.</MessageContent> </Message> <Message from="assistant"> <MessageContent> <MessageResponse /> </MessageContent> <MessageToolbar> <MessageActions> <MessageAction tooltip="Copy"> <CopyIcon /> </MessageAction> <MessageAction tooltip="Regenerate"> <ArrowClockwiseIcon /> </MessageAction> <MessageAction tooltip="Good response"> <ThumbsUpIcon /> </MessageAction> </MessageActions> </MessageToolbar> </Message></div>Component source
Section titled “Component source”"use client";
import { cjk } from "@streamdown/cjk";import { code } from "@streamdown/code";import { math } from "@streamdown/math";import { mermaid } from "@streamdown/mermaid";import type { UIMessage } from "ai";import { cn } from "cn";import type { ComponentProps, HTMLAttributes, ReactElement } from "react";import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState,} from "react";import { Streamdown } from "streamdown";import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";import { Bubble, BubbleContent } from "@/registry/edmi/ui/bubble";import { Button } from "@/registry/edmi/ui/button";import { ButtonGroup, ButtonGroupText } from "@/registry/edmi/ui/button-group";import { Message as UiMessage, MessageAvatar as UiMessageAvatar, MessageContent as UiMessageContent, MessageHeader as UiMessageHeader,} from "@/registry/edmi/ui/message";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/registry/edmi/ui/tooltip";
// Thin layer on ui/message + ui/bubble (DESIGN §5b). Assistant turns have NO avatar by default: the response// is full width on the left; a user turn is a secondary bubble on the right. `MessageAvatar` + `MessageHeader`// are opt-in for multi-agent chats (the avatar then aligns to the name line). Status / shimmer text goes inside// `MessageContent` (a ghost bubble) so its first line aligns like any message.
const MessageContext = createContext<{ from: UIMessage["role"] }>({ from: "assistant",});
export type MessageProps = Omit<ComponentProps<typeof UiMessage>, "align"> & { from: UIMessage["role"];};
export const Message = ({ className, from, ...props }: MessageProps) => ( <MessageContext.Provider value={{ from }}> <UiMessage data-slot="ai-message" data-from={from} align={from === "user" ? "end" : "start"} className={cn( "max-w-full flex-col items-stretch gap-1.5 data-[align=end]:flex-col", // opt-in avatar: two columns, avatar spans the rows, everything else stacks in column 2 "has-[>[data-slot=message-avatar]]:grid has-[>[data-slot=message-avatar]]:grid-cols-[auto_minmax(0,1fr)] has-[>[data-slot=message-avatar]]:items-start has-[>[data-slot=message-avatar]]:gap-x-2.5 has-[>[data-slot=message-avatar]]:gap-y-1.5 [&>[data-slot=message-avatar]]:row-span-full has-[>[data-slot=message-avatar]]:[&>:not([data-slot=message-avatar])]:col-start-2", className, )} {...props} /> </MessageContext.Provider>);
export type MessageContentProps = ComponentProps<typeof BubbleContent> & { /** Defaults to `secondary` for the user and `ghost` (no fill, keeps vertical padding) for the assistant. */ variant?: ComponentProps<typeof Bubble>["variant"];};
export const MessageContent = ({ className, variant, ...props}: MessageContentProps) => { const { from } = useContext(MessageContext); const user = from === "user"; return ( <UiMessageContent data-slot="ai-message-content"> <Bubble variant={variant ?? (user ? "secondary" : "ghost")} align={user ? "end" : "start"} className={cn(!user && "w-full")} > <BubbleContent className={cn(!user && "w-full", className)} {...props} /> </Bubble> </UiMessageContent> );};
/** Opt-in (multi-agent). Same part as `ui/message` `MessageAvatar`. */export const MessageAvatar = UiMessageAvatar;/** Opt-in (multi-agent): the sender name line the avatar aligns to. */export const MessageHeader = UiMessageHeader;
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({ className, children, ...props}: MessageActionsProps) => ( <div data-slot="ai-message-actions" className={cn("flex items-center gap-0.5", className)} {...props} > {children} </div>);
export type MessageActionProps = ComponentProps<typeof Button> & { tooltip?: string; label?: string;};
export const MessageAction = ({ tooltip, children, label, variant = "ghost", size = "icon-xs", ...props}: MessageActionProps) => { const button = ( <Button size={size} type="button" variant={variant} {...props}> {children} <span className="sr-only">{label || tooltip}</span> </Button> );
if (tooltip) { return ( <TooltipProvider> <Tooltip> <TooltipTrigger render={button} /> <TooltipContent> <p>{tooltip}</p> </TooltipContent> </Tooltip> </TooltipProvider> ); }
return button;};
interface MessageBranchContextType { currentBranch: number; totalBranches: number; goToPrevious: () => void; goToNext: () => void; branches: ReactElement[]; setBranches: (branches: ReactElement[]) => void;}
const MessageBranchContext = createContext<MessageBranchContextType | null>( null,);
const useMessageBranch = () => { const context = useContext(MessageBranchContext);
if (!context) { throw new Error( "MessageBranch components must be used within MessageBranch", ); }
return context;};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & { defaultBranch?: number; onBranchChange?: (branchIndex: number) => void;};
export const MessageBranch = ({ defaultBranch = 0, onBranchChange, className, ...props}: MessageBranchProps) => { const [currentBranch, setCurrentBranch] = useState(defaultBranch); const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback( (newBranch: number) => { setCurrentBranch(newBranch); onBranchChange?.(newBranch); }, [onBranchChange], );
const goToPrevious = useCallback(() => { const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1; handleBranchChange(newBranch); }, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => { const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0; handleBranchChange(newBranch); }, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>( () => ({ branches, currentBranch, goToNext, goToPrevious, setBranches, totalBranches: branches.length, }), [branches, currentBranch, goToNext, goToPrevious], );
return ( <MessageBranchContext.Provider value={contextValue}> <div data-slot="ai-message-branch" className={cn("grid w-full gap-1.5 [&>div]:pb-0", className)} {...props} /> </MessageBranchContext.Provider> );};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({ children, ...props}: MessageBranchContentProps) => { const { currentBranch, setBranches, branches } = useMessageBranch(); const childrenArray = useMemo( () => (Array.isArray(children) ? children : [children]), [children], );
// Keep the branch list in sync with the children. useEffect(() => { if (branches.length !== childrenArray.length) { setBranches(childrenArray); } }, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => ( <div className={cn( "grid gap-1.5 overflow-hidden [&>div]:pb-0", index === currentBranch ? "block" : "hidden", )} key={branch.key} {...props} > {branch} </div> ));};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({ className, ...props}: MessageBranchSelectorProps) => { const { totalBranches } = useMessageBranch();
// Nothing to switch between. if (totalBranches <= 1) { return null; }
return ( <ButtonGroup data-slot="ai-message-branch-selector" className={cn("items-center gap-0.5 [&>*]:rounded-md", className)} orientation="horizontal" {...props} /> );};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({ children, ...props}: MessageBranchPreviousProps) => { const { goToPrevious, totalBranches } = useMessageBranch();
return ( <Button aria-label="Previous branch" disabled={totalBranches <= 1} onClick={goToPrevious} size="icon-xs" type="button" variant="ghost" {...props} > {children ?? ( <CaretLeftIcon /> )} </Button> );};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({ children, ...props}: MessageBranchNextProps) => { const { goToNext, totalBranches } = useMessageBranch();
return ( <Button aria-label="Next branch" disabled={totalBranches <= 1} onClick={goToNext} size="icon-xs" type="button" variant="ghost" {...props} > {children ?? ( <CaretRightIcon /> )} </Button> );};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({ className, ...props}: MessageBranchPageProps) => { const { currentBranch, totalBranches } = useMessageBranch();
return ( <ButtonGroupText className={cn( "border-none bg-transparent px-1 font-mono text-xs text-muted-foreground tabular-nums shadow-none", className, )} {...props} > {currentBranch + 1} / {totalBranches} </ButtonGroupText> );};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
// Response typography (DESIGN §5b rule 3): 15/1.65, 600 lead-ins, h1-h3 22/18/16, inline code = mono 12.5 on// --muted in --destructive-text, links --info-text underlined, quote = 3px --border rule, 22px list indent,// max ~68ch. Selectors target Streamdown's `data-streamdown` hooks so its own utility classes lose.const responseTypography = [ "max-w-[68ch] text-[15px] leading-[1.65] text-foreground", "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&_p]:my-0 [&_p+*]:mt-2.5", "[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-[22px] [&_h1]:leading-snug [&_h1]:font-semibold [&_h1]:tracking-[-0.3px]", "[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:leading-snug [&_h2]:font-semibold [&_h2]:tracking-[-0.2px]", "[&_h3]:mt-3 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:leading-snug [&_h3]:font-semibold", "[&_[data-streamdown=strong]]:font-semibold", "[&_[data-streamdown=inline-code]]:rounded-md [&_[data-streamdown=inline-code]]:border [&_[data-streamdown=inline-code]]:border-border [&_[data-streamdown=inline-code]]:bg-muted [&_[data-streamdown=inline-code]]:px-1.5 [&_[data-streamdown=inline-code]]:py-px [&_[data-streamdown=inline-code]]:font-mono [&_[data-streamdown=inline-code]]:text-[12.5px] [&_[data-streamdown=inline-code]]:font-normal [&_[data-streamdown=inline-code]]:text-destructive-text", "[&_[data-streamdown=link]]:font-normal [&_[data-streamdown=link]]:text-info-text [&_[data-streamdown=link]]:underline [&_[data-streamdown=link]]:underline-offset-[3px]", "[&_[data-streamdown=blockquote]]:my-2.5 [&_[data-streamdown=blockquote]]:border-l-[3px] [&_[data-streamdown=blockquote]]:border-border [&_[data-streamdown=blockquote]]:py-0.5 [&_[data-streamdown=blockquote]]:pl-3.5 [&_[data-streamdown=blockquote]]:text-foreground-2 [&_[data-streamdown=blockquote]]:not-italic", "[&_[data-streamdown=unordered-list]]:my-2.5 [&_[data-streamdown=unordered-list]]:list-outside [&_[data-streamdown=unordered-list]]:pl-[22px] [&_[data-streamdown=ordered-list]]:my-2.5 [&_[data-streamdown=ordered-list]]:list-outside [&_[data-streamdown=ordered-list]]:pl-[22px] [&_[data-streamdown=list-item]]:py-0.5 [&_[data-streamdown=list-item]]:pl-0",].join(" ");
export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( <Streamdown className={cn("size-full", responseTypography, className)} plugins={streamdownPlugins} {...props} /> ), (prevProps, nextProps) => prevProps.children === nextProps.children && nextProps.isAnimating === prevProps.isAnimating,);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({ className, children, ...props}: MessageToolbarProps) => ( <div data-slot="ai-message-toolbar" className={cn("flex w-full items-center justify-between gap-4", className)} {...props} > {children} </div>);<script setup lang="ts">import type { UIMessage } from "ai"import type { HTMLAttributes } from "vue"import { computed, provide } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Message as UiMessage } from "@/registry/edmi/ui/message"import { MessageKey } from "./context"
// Thin layer on ui/message + ui/bubble (DESIGN §5b). Assistant turns have NO avatar by default: the response// is full width on the left; a user turn is a secondary bubble on the right. `MessageAvatar` + `MessageHeader`// are opt-in for multi-agent chats (the avatar then aligns to the name line). Status / shimmer text goes inside// `MessageContent` (a ghost bubble) so its first line aligns like any message.
const props = defineProps<{ from: UIMessage["role"] class?: HTMLAttributes["class"]}>()
provide(MessageKey, { from: computed(() => props.from) })</script>
<template> <UiMessage data-slot="ai-message" :data-from="from" :align="from === 'user' ? 'end' : 'start'" :class="cn( 'max-w-full flex-col items-stretch gap-1.5 data-[align=end]:flex-col', // opt-in avatar: two columns, avatar spans the rows, everything else stacks in column 2 'has-[>[data-slot=message-avatar]]:grid has-[>[data-slot=message-avatar]]:grid-cols-[auto_minmax(0,1fr)] has-[>[data-slot=message-avatar]]:items-start has-[>[data-slot=message-avatar]]:gap-x-2.5 has-[>[data-slot=message-avatar]]:gap-y-1.5 [&>[data-slot=message-avatar]]:row-span-full has-[>[data-slot=message-avatar]]:[&>:not([data-slot=message-avatar])]:col-start-2', props.class, )" > <slot /> </UiMessage></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { Button } from "@/registry/edmi/ui/button"import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/registry/edmi/ui/tooltip"
const props = withDefaults(defineProps<{ tooltip?: string label?: string variant?: ButtonVariants["variant"] size?: ButtonVariants["size"] class?: HTMLAttributes["class"]}>(), { variant: "ghost", size: "icon-xs",})</script>
<template> <TooltipProvider v-if="props.tooltip"> <Tooltip> <TooltipTrigger as-child> <Button type="button" :variant="props.variant" :size="props.size" :class="props.class"> <slot /> <span class="sr-only">{{ props.label || props.tooltip }}</span> </Button> </TooltipTrigger> <TooltipContent> <p>{{ props.tooltip }}</p> </TooltipContent> </Tooltip> </TooltipProvider> <Button v-else type="button" :variant="props.variant" :size="props.size" :class="props.class"> <slot /> <span class="sr-only">{{ props.label }}</span> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div data-slot="ai-message-actions" :class="cn('flex items-center gap-0.5', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { MessageBranchContextType } from "./context"import { provide, readonly, ref } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { MessageBranchKey } from "./context"
const props = withDefaults(defineProps<{ defaultBranch?: number class?: HTMLAttributes["class"]}>(), { defaultBranch: 0,})
const emits = defineEmits<{ (e: "branchChange", branchIndex: number): void}>()
const currentBranch = ref<number>(props.defaultBranch)const totalBranches = ref<number>(0)
function handleBranchChange(index: number) { currentBranch.value = index emits("branchChange", index)}
function goToPrevious() { if (totalBranches.value === 0) return handleBranchChange(currentBranch.value > 0 ? currentBranch.value - 1 : totalBranches.value - 1)}
function goToNext() { if (totalBranches.value === 0) return handleBranchChange(currentBranch.value < totalBranches.value - 1 ? currentBranch.value + 1 : 0)}
const contextValue: MessageBranchContextType = { currentBranch: readonly(currentBranch), totalBranches: readonly(totalBranches), goToPrevious, goToNext, setBranches: (count: number) => { totalBranches.value = count },}
provide(MessageBranchKey, contextValue)</script>
<template> <div data-slot="ai-message-branch" :class="cn('grid w-full gap-1.5 [&>div]:pb-0', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed, Fragment, isVNode, onMounted, useSlots, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useMessageBranchContext } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()
const slots = useSlots()const { currentBranch, setBranches } = useMessageBranchContext()
// Every direct child of the default slot is one branch (v-for children are flattened).const branchVNodes = computed(() => { const flatten = (nodes: unknown[]): unknown[] => nodes.flatMap(node => isVNode(node) && node.type === Fragment && Array.isArray(node.children) ? flatten(node.children) : [node]) return flatten(slots.default?.() ?? []).filter(node => isVNode(node) && typeof node.type !== "symbol")})
const sync = () => setBranches(branchVNodes.value.length)onMounted(sync)watch(() => branchVNodes.value.length, sync)</script>
<template> <template v-for="(node, index) in branchVNodes" :key="(node as any).key ?? index"> <div :class="cn('grid gap-1.5 overflow-hidden [&>div]:pb-0', index === currentBranch ? 'block' : 'hidden', props.class)"> <component :is="node as any" /> </div> </template></template><script setup lang="ts">import { ChevronRightIcon } from "@lucide/vue"import { Button } from "@/registry/edmi/ui/button"import { useMessageBranchContext } from "./context"
const { goToNext, totalBranches } = useMessageBranchContext()</script>
<template> <Button aria-label="Next branch" :disabled="totalBranches <= 1" size="icon-xs" type="button" variant="ghost" @click="goToNext" > <slot> <ChevronRightIcon /> </slot> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { ButtonGroupText } from "@/registry/edmi/ui/button-group"import { useMessageBranchContext } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()
const { currentBranch, totalBranches } = useMessageBranchContext()</script>
<template> <ButtonGroupText :class="cn( 'border-none bg-transparent px-1 font-mono text-xs text-muted-foreground tabular-nums shadow-none', props.class, )" > {{ currentBranch + 1 }} / {{ totalBranches }} </ButtonGroupText></template><script setup lang="ts">import { ChevronLeftIcon } from "@lucide/vue"import { Button } from "@/registry/edmi/ui/button"import { useMessageBranchContext } from "./context"
const { goToPrevious, totalBranches } = useMessageBranchContext()</script>
<template> <Button aria-label="Previous branch" :disabled="totalBranches <= 1" size="icon-xs" type="button" variant="ghost" @click="goToPrevious" > <slot> <ChevronLeftIcon /> </slot> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { ButtonGroup } from "@/registry/edmi/ui/button-group"import { useMessageBranchContext } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()
const { totalBranches } = useMessageBranchContext()</script>
<template> <!-- Nothing to switch between with a single branch. --> <ButtonGroup v-if="totalBranches > 1" data-slot="ai-message-branch-selector" orientation="horizontal" :class="cn('items-center gap-0.5 [&>*]:rounded-md', props.class)" > <slot /> </ButtonGroup></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { BubbleVariants } from "@/registry/edmi/ui/bubble"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Bubble, BubbleContent } from "@/registry/edmi/ui/bubble"import { MessageContent as UiMessageContent } from "@/registry/edmi/ui/message"import { useMessageContext } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] /** Defaults to `secondary` for the user and `ghost` (no fill, keeps vertical padding) for the assistant. */ variant?: BubbleVariants["variant"]}>()
const ctx = useMessageContext()const user = computed(() => ctx?.from.value === "user")</script>
<template> <UiMessageContent data-slot="ai-message-content"> <Bubble :variant="props.variant ?? (user ? 'secondary' : 'ghost')" :align="user ? 'end' : 'start'" :class="cn(!user && 'w-full')" > <BubbleContent :class="cn(!user && 'w-full', props.class)"> <slot /> </BubbleContent> </Bubble> </UiMessageContent></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed, useSlots } from "vue"import { Markdown } from "vue-stream-markdown"import { cn } from "@/registry/edmi/lib/utils"import "vue-stream-markdown/index.css"
const props = withDefaults(defineProps<{ /** Markdown source; the default slot text works too. */ content?: string /** `streaming` fades words in as they arrive; `static` (default) renders at once. */ mode?: "static" | "streaming" class?: HTMLAttributes["class"]}>(), { mode: "static" })
const slots = useSlots()const slotContent = computed<string | undefined>(() => { const nodes = slots.default?.() if (!Array.isArray(nodes)) return undefined let text = "" for (const node of nodes) { if (typeof node.children === "string") text += node.children } return text || undefined})
const md = computed(() => (slotContent.value ?? props.content ?? "") as string)
// Response typography (DESIGN §5b rule 3): 15/1.65, 600 lead-ins, h1-h3 22/18/16, inline code = mono 12.5 on// --muted in --destructive-text, links --info-text underlined, quote = 3px --border rule, 22px list indent,// max ~68ch. Selectors target vue-stream-markdown's `data-stream-markdown` hooks; `!` beats its own unlayered// scoped rules (utilities live in a CSS layer and would lose).const responseTypography = [ "max-w-[68ch] text-[15px] leading-[1.65] text-foreground", "[&>*:first-child]:mt-0! [&>*:last-child]:mb-0! [&>*+*]:mt-2.5!", "[&_[data-stream-markdown=p]]:my-0!", "[&_[data-stream-markdown=heading-1]]:mt-4! [&_[data-stream-markdown=heading-1]]:mb-2! [&_[data-stream-markdown=heading-1]]:text-[22px]! [&_[data-stream-markdown=heading-1]]:leading-snug! [&_[data-stream-markdown=heading-1]]:font-semibold! [&_[data-stream-markdown=heading-1]]:tracking-[-0.3px]!", "[&_[data-stream-markdown=heading-2]]:mt-4! [&_[data-stream-markdown=heading-2]]:mb-2! [&_[data-stream-markdown=heading-2]]:text-lg! [&_[data-stream-markdown=heading-2]]:leading-snug! [&_[data-stream-markdown=heading-2]]:font-semibold! [&_[data-stream-markdown=heading-2]]:tracking-[-0.2px]!", "[&_[data-stream-markdown=heading-3]]:mt-3! [&_[data-stream-markdown=heading-3]]:mb-2! [&_[data-stream-markdown=heading-3]]:text-base! [&_[data-stream-markdown=heading-3]]:leading-snug! [&_[data-stream-markdown=heading-3]]:font-semibold!", "[&_[data-stream-markdown=strong]]:font-semibold!", "[&_[data-stream-markdown=code]]:rounded-md! [&_[data-stream-markdown=code]]:border! [&_[data-stream-markdown=code]]:border-border! [&_[data-stream-markdown=code]]:bg-muted! [&_[data-stream-markdown=code]]:px-1.5! [&_[data-stream-markdown=code]]:py-px! [&_[data-stream-markdown=code]]:font-mono! [&_[data-stream-markdown=code]]:text-[12.5px]! [&_[data-stream-markdown=code]]:font-normal! [&_[data-stream-markdown=code]]:text-destructive-text!", "[&_[data-stream-markdown=link]]:font-normal! [&_[data-stream-markdown=link]]:text-info-text! [&_[data-stream-markdown=link]]:underline! [&_[data-stream-markdown=link]]:underline-offset-[3px]!", "[&_[data-stream-markdown=blockquote]]:my-2.5! [&_[data-stream-markdown=blockquote]]:border-l-[3px]! [&_[data-stream-markdown=blockquote]]:border-l-border! [&_[data-stream-markdown=blockquote]]:py-0.5! [&_[data-stream-markdown=blockquote]]:pl-3.5! [&_[data-stream-markdown=blockquote]]:text-foreground-2! [&_[data-stream-markdown=blockquote]]:not-italic!", "[&_[data-stream-markdown=ul]]:my-2.5! [&_[data-stream-markdown=ol]]:my-2.5!", "[&_[data-stream-markdown=li]]:py-0.5! [&_[data-stream-markdown=li]]:pl-0! [&_[data-stream-markdown=li]]:grid-cols-[22px_minmax(0,1fr)]! [&_[data-stream-markdown=li]]:gap-x-0!", "[&_[data-stream-markdown=list-marker]]:pr-1.5!",].join(" ")</script>
<template> <Markdown data-slot="ai-message-response" :content="md" :mode="props.mode" :enable-animate="props.mode === 'streaming'" :link-options="{ favicon: false }" :class="cn('size-full', responseTypography, props.class)" /></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div data-slot="ai-message-toolbar" :class="cn('flex w-full items-center justify-between gap-4', props.class)"> <slot /> </div></template>import type { UIMessage } from "ai";import type { InjectionKey, Ref } from "vue";import { inject } from "vue";
export interface MessageContextType { from: Ref<UIMessage["role"]>;}
export const MessageKey: InjectionKey<MessageContextType> = Symbol("Message");
export function useMessageContext(): MessageContextType | null { return inject(MessageKey, null);}
export interface MessageBranchContextType { currentBranch: Readonly<Ref<number>>; totalBranches: Readonly<Ref<number>>; goToPrevious: () => void; goToNext: () => void; setBranches: (count: number) => void;}
export const MessageBranchKey: InjectionKey<MessageBranchContextType> = Symbol("MessageBranch");
export function useMessageBranchContext(): MessageBranchContextType { const ctx = inject(MessageBranchKey); if (!ctx) { throw new Error( "MessageBranch components must be used within MessageBranch", ); } return ctx;}// Opt-in (multi-agent): same parts as ui/message, the avatar aligns to the name line.// Imported then re-exported: the shadcn-vue CLI only rewrites `import` specifiers, not `export ... from`.import { MessageAvatar, MessageHeader } from "@/registry/edmi/ui/message";
export * from "./context";export { default as Message } from "./Message.vue";export { default as MessageAction } from "./MessageAction.vue";export { default as MessageActions } from "./MessageActions.vue";export { default as MessageBranch } from "./MessageBranch.vue";export { default as MessageBranchContent } from "./MessageBranchContent.vue";export { default as MessageBranchNext } from "./MessageBranchNext.vue";export { default as MessageBranchPage } from "./MessageBranchPage.vue";export { default as MessageBranchPrevious } from "./MessageBranchPrevious.vue";export { default as MessageBranchSelector } from "./MessageBranchSelector.vue";export { default as MessageContent } from "./MessageContent.vue";export { default as MessageResponse } from "./MessageResponse.vue";export { default as MessageToolbar } from "./MessageToolbar.vue";export { MessageAvatar, MessageHeader };import Root from "./message.svelte";import Action from "./message-action.svelte";import Actions from "./message-actions.svelte";import Branch from "./message-branch.svelte";import BranchContent from "./message-branch-content.svelte";import BranchNext from "./message-branch-next.svelte";import BranchPage from "./message-branch-page.svelte";import BranchPrevious from "./message-branch-previous.svelte";import BranchSelector from "./message-branch-selector.svelte";import Content from "./message-content.svelte";import Response from "./message-response.svelte";import Toolbar from "./message-toolbar.svelte";
// Opt-in (multi-agent): same parts as ui/message, the avatar aligns to the name line.export { MessageAvatar, MessageHeader,} from "$lib/registry/ui/message/index.js";export * from "./use-message.svelte.js";export { Action, Action as MessageAction, Actions, Actions as MessageActions, Branch, Branch as MessageBranch, BranchContent, BranchContent as MessageBranchContent, BranchNext, BranchNext as MessageBranchNext, BranchPage, BranchPage as MessageBranchPage, BranchPrevious, BranchPrevious as MessageBranchPrevious, BranchSelector, BranchSelector as MessageBranchSelector, Content, Content as MessageContent, Response, Response as MessageResponse, Root, // Root as Message, Toolbar, Toolbar as MessageToolbar,};<script lang="ts"> import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import * as Tooltip from "$lib/registry/ui/tooltip/index.js";
let { tooltip, label, variant = "ghost", size = "icon-xs", children, ...restProps }: Omit<ButtonProps, "href"> & { tooltip?: string; label?: string; } = $props();
const srOnlyLabel = $derived(label || tooltip);</script>
{#if tooltip} <Tooltip.Provider> <Tooltip.Root> <Tooltip.Trigger> {#snippet child({ props })} <Button {...props} {...restProps} {size} type="button" {variant}> {@render children?.()} {#if srOnlyLabel} <span class="sr-only">{srOnlyLabel}</span> {/if} </Button> {/snippet} </Tooltip.Trigger> <Tooltip.Content> <p>{tooltip}</p> </Tooltip.Content> </Tooltip.Root> </Tooltip.Provider>{:else} <Button {...restProps} {size} type="button" {variant}> {@render children?.()} {#if srOnlyLabel} <span class="sr-only">{srOnlyLabel}</span> {/if} </Button>{/if}<script lang="ts"> 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>
<div data-slot="ai-message-actions" class={cn("flex items-center gap-0.5", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { useMessageBranch } from "./use-message.svelte.js";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();
const branches = useMessageBranch(); let el = $state<HTMLElement | null>(null);
// Every direct child of the wrapper is one branch (`{#each}` children included): count them and // show only the current one. $effect(() => { if (!el) return; const root = el; const sync = () => { const items = Array.from(root.children) as HTMLElement[]; branches.setTotalBranches(items.length); items.forEach((item, index) => { item.style.display = index === branches.currentBranch ? "" : "none"; }); }; sync(); const observer = new MutationObserver(sync); observer.observe(root, { childList: true }); return () => observer.disconnect(); });
// Re-apply visibility when the current branch changes. $effect(() => { const current = branches.currentBranch; if (!el) return; (Array.from(el.children) as HTMLElement[]).forEach((item, index) => { item.style.display = index === current ? "" : "none"; }); });</script>
<div bind:this={el} data-slot="ai-message-branch-content" class={cn("grid gap-1.5 overflow-hidden [&>div]:pb-0", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import CaretRightIcon from "phosphor-svelte/lib/CaretRight"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { useMessageBranch } from "./use-message.svelte.js";
let { children, ...restProps }: Omit<ButtonProps, "href" | "onclick"> = $props();
const branches = useMessageBranch();</script>
<Button aria-label="Next branch" disabled={branches.totalBranches <= 1} onclick={() => branches.goToNext()} size="icon-xs" type="button" variant="ghost" {...restProps}> {#if children} {@render children()} {:else} <CaretRightIcon /> {/if}</Button><script lang="ts"> import { ButtonGroupText } from "$lib/registry/ui/button-group/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte"; import { useMessageBranch } from "./use-message.svelte.js";
let { class: className, ...restProps }: ComponentProps<typeof ButtonGroupText> = $props();
const branches = useMessageBranch();</script>
<ButtonGroupText class={cn( "border-none bg-transparent px-1 font-mono text-xs text-muted-foreground tabular-nums shadow-none", className )} {...restProps}> {branches.currentBranch + 1} / {branches.totalBranches}</ButtonGroupText><script lang="ts"> import CaretLeftIcon from "phosphor-svelte/lib/CaretLeft"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { useMessageBranch } from "./use-message.svelte.js";
let { children, ...restProps }: Omit<ButtonProps, "href" | "onclick"> = $props();
const branches = useMessageBranch();</script>
<Button aria-label="Previous branch" disabled={branches.totalBranches <= 1} onclick={() => branches.goToPrevious()} size="icon-xs" type="button" variant="ghost" {...restProps}> {#if children} {@render children()} {:else} <CaretLeftIcon /> {/if}</Button><script lang="ts"> import { ButtonGroup } from "$lib/registry/ui/button-group/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte"; import { useMessageBranch } from "./use-message.svelte.js";
let { class: className, children, ...restProps }: Omit<ComponentProps<typeof ButtonGroup>, "orientation"> = $props();
const branches = useMessageBranch();</script>
<!-- Nothing to switch between with a single branch. -->{#if branches.totalBranches > 1} <ButtonGroup data-slot="ai-message-branch-selector" orientation="horizontal" class={cn("items-center gap-0.5 [&>*]:rounded-md", className)} {...restProps} > {@render children?.()} </ButtonGroup>{/if}<script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { MessageBranchController, setMessageBranchContext } from "./use-message.svelte.js";
let { defaultBranch = 0, onBranchChange, class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { defaultBranch?: number; onBranchChange?: (branchIndex: number) => void; children?: Snippet; } = $props();
// svelte-ignore state_referenced_locally const branches = new MessageBranchController(defaultBranch); $effect(() => branches.setOnChange(onBranchChange)); setMessageBranchContext(branches);</script>
<div data-slot="ai-message-branch" class={cn("grid w-full gap-1.5 [&>div]:pb-0", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { Bubble, BubbleContent, type BubbleVariant, } from "$lib/registry/ui/bubble/index.js"; import { MessageContent as UiMessageContent } from "$lib/registry/ui/message/index.js"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { useMessageContext } from "./use-message.svelte.js";
let { class: className, variant, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { /** Defaults to `secondary` for the user and `ghost` (no fill, keeps vertical padding) for the assistant. */ variant?: BubbleVariant; children?: Snippet; } = $props();
const message = useMessageContext(); const user = $derived(message?.from === "user");</script>
<UiMessageContent data-slot="ai-message-content"> <Bubble variant={variant ?? (user ? "secondary" : "ghost")} align={user ? "end" : "start"} class={cn(!user && "w-full")} > <BubbleContent class={cn(!user && "w-full", className)} {...(restProps as Record<string, unknown>)}> {@render children?.()} </BubbleContent> </Bubble></UiMessageContent><script lang="ts"> import { cn } from "$lib/utils.js"; import { Streamdown, type StreamdownProps } from "svelte-streamdown";
let { content, class: className, theme, ...restProps }: StreamdownProps = $props();
// Response typography (DESIGN §5b rule 3): 15/1.65, 600 lead-ins, h1-h3 22/18/16, inline code = mono 12.5 on // --muted in --destructive-text, links --info-text underlined, quote = 3px --border rule, 22px list indent, // max ~68ch. Passed through Streamdown's theme (merged with tailwind-merge, so these win over the shadcn base). const typography = { h1: { base: "mt-4 mb-2 text-[22px] leading-snug font-semibold tracking-[-0.3px]" }, h2: { base: "mt-4 mb-2 text-lg leading-snug font-semibold tracking-[-0.2px]" }, h3: { base: "mt-3 mb-2 text-base leading-snug font-semibold" }, paragraph: { base: "my-0" }, strong: { base: "font-semibold" }, codespan: { base: "rounded-md border border-border bg-muted px-1.5 py-px font-mono text-[12.5px] font-normal text-destructive-text", }, link: { base: "font-normal text-info-text underline underline-offset-[3px] hover:text-info-text", }, blockquote: { base: "my-2.5 border-l-[3px] border-border py-0.5 pl-3.5 text-foreground-2 not-italic", }, ul: { base: "my-2.5 ml-0 list-outside pl-[22px]" }, ol: { base: "my-2.5 ml-0 list-outside pl-[22px]" }, li: { base: "py-0.5 pl-0" }, };</script>
<div data-slot="ai-message-response" class={cn( "size-full max-w-[68ch] text-[15px] leading-[1.65] text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className )}> <Streamdown {content} baseTheme="shadcn" theme={{ ...typography, ...theme }} {...restProps} /></div><script lang="ts"> 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>
<div data-slot="ai-message-toolbar" class={cn("flex w-full items-center justify-between gap-4", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { Message as UiMessage } from "$lib/registry/ui/message/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte"; import { type MessageRole, setMessageContext } from "./use-message.svelte.js";
// Thin layer on ui/message + ui/bubble (DESIGN §5b). Assistant turns have NO avatar by default: the response // is full width on the left; a user turn is a secondary bubble on the right. `MessageAvatar` + `MessageHeader` // are opt-in for multi-agent chats (the avatar then aligns to the name line). Status / shimmer text goes inside // `MessageContent` (a ghost bubble) so its first line aligns like any message.
let { from, class: className, children, ...restProps }: Omit<ComponentProps<typeof UiMessage>, "align"> & { from: MessageRole } = $props();
setMessageContext({ get from() { return from; }, });</script>
<UiMessage data-slot="ai-message" data-from={from} align={from === "user" ? "end" : "start"} class={cn( "max-w-full flex-col items-stretch gap-1.5 data-[align=end]:flex-col", // opt-in avatar: two columns, avatar spans the rows, everything else stacks in column 2 "has-[>[data-slot=message-avatar]]:grid has-[>[data-slot=message-avatar]]:grid-cols-[auto_minmax(0,1fr)] has-[>[data-slot=message-avatar]]:items-start has-[>[data-slot=message-avatar]]:gap-x-2.5 has-[>[data-slot=message-avatar]]:gap-y-1.5 [&>[data-slot=message-avatar]]:row-span-full has-[>[data-slot=message-avatar]]:[&>:not([data-slot=message-avatar])]:col-start-2", className )} {...restProps}> {@render children?.()}</UiMessage>import type { UIMessage } from "ai";import { getContext, setContext } from "svelte";
export type MessageRole = UIMessage["role"];
/** Getter object so consumers stay reactive when `from` changes. */export interface MessageContextValue { readonly from: MessageRole;}
const MESSAGE_KEY = Symbol("ai-message");const BRANCH_KEY = Symbol("ai-message-branch");
export function setMessageContext(value: MessageContextValue) { return setContext(MESSAGE_KEY, value);}
export function useMessageContext(): MessageContextValue | undefined { return getContext<MessageContextValue | undefined>(MESSAGE_KEY);}
export class MessageBranchController { currentBranch = $state(0); totalBranches = $state(0); #onChange?: (index: number) => void;
constructor(defaultBranch = 0, onChange?: (index: number) => void) { this.currentBranch = defaultBranch; this.#onChange = onChange; }
setOnChange(onChange?: (index: number) => void) { this.#onChange = onChange; }
setTotalBranches(count: number) { this.totalBranches = Math.max(0, count); if (this.totalBranches > 0 && this.currentBranch >= this.totalBranches) { this.currentBranch = this.totalBranches - 1; } }
#go(index: number) { this.currentBranch = index; this.#onChange?.(index); }
goToPrevious() { if (this.totalBranches <= 1) return; this.#go( this.currentBranch > 0 ? this.currentBranch - 1 : this.totalBranches - 1, ); }
goToNext() { if (this.totalBranches <= 1) return; this.#go( this.currentBranch < this.totalBranches - 1 ? this.currentBranch + 1 : 0, ); }}
export function setMessageBranchContext(controller: MessageBranchController) { return setContext(BRANCH_KEY, controller);}
export function useMessageBranch(): MessageBranchController { const ctx = getContext<MessageBranchController | undefined>(BRANCH_KEY); if (!ctx) { throw new Error( "MessageBranch components must be used within MessageBranch", ); } return ctx;}More examples
Section titled “More examples”Branches
Section titled “Branches”import { Message, MessageBranch, MessageBranchContent, MessageBranchNext, MessageBranchPage, MessageBranchPrevious, MessageBranchSelector, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";
const variants = [ "Sell **0.42 NVDAx** and buy the other two.", "Skip the rebalance: drift is **1.9%**, under your limit.", "Rebalance in two steps to keep slippage under `0.05%`.",];
export default function Demo() { return ( <MessageBranch className="max-w-xl"> <MessageBranchContent> {variants.map((text) => ( <Message from="assistant" key={text}> <MessageContent> <MessageResponse>{text}</MessageResponse> </MessageContent> </Message> ))} </MessageBranchContent> <MessageToolbar> <MessageBranchSelector> <MessageBranchPrevious /> <MessageBranchPage /> <MessageBranchNext /> </MessageBranchSelector> </MessageToolbar> </MessageBranch> );}<script setup lang="ts">import { Message, MessageBranch, MessageBranchContent, MessageBranchNext, MessageBranchPage, MessageBranchPrevious, MessageBranchSelector, MessageContent, MessageResponse, MessageToolbar,} from "@/components/ai/message";
const variants = [ "Sell **0.42 NVDAx** and buy the other two.", "Skip the rebalance: drift is **1.9%**, under your limit.", "Rebalance in two steps to keep slippage under `0.05%`.",];</script>
<template> <MessageBranch class="max-w-xl"> <MessageBranchContent> <Message v-for="text in variants" :key="text" from="assistant"> <MessageContent> <MessageResponse :content="text" /> </MessageContent> </Message> </MessageBranchContent> <MessageToolbar> <MessageBranchSelector> <MessageBranchPrevious /> <MessageBranchPage /> <MessageBranchNext /> </MessageBranchSelector> </MessageToolbar> </MessageBranch></template><script lang="ts"> import { Message, MessageBranch, MessageBranchContent, MessageBranchNext, MessageBranchPage, MessageBranchPrevious, MessageBranchSelector, MessageContent, MessageResponse, MessageToolbar, } from "$lib/components/ai/message";
const variants = [ "Sell **0.42 NVDAx** and buy the other two.", "Skip the rebalance: drift is **1.9%**, under your limit.", "Rebalance in two steps to keep slippage under `0.05%`.", ];</script>
<MessageBranch class="max-w-xl"> <MessageBranchContent> {#each variants as text (text)} <Message from="assistant"> <MessageContent> <MessageResponse content={text} /> </MessageContent> </Message> {/each} </MessageBranchContent> <MessageToolbar> <MessageBranchSelector> <MessageBranchPrevious /> <MessageBranchPage /> <MessageBranchNext /> </MessageBranchSelector> </MessageToolbar></MessageBranch>Multi-agent (avatar and name)
Section titled “Multi-agent (avatar and name)”import { Message, MessageAvatar, MessageContent, MessageHeader,} from "@/components/ai/message";
const agents = [ { name: "Keeper", text: "Drift is 2.4%, above the 2% limit. Rebalance is allowed.", tone: "bg-success-soft text-success-text", }, { name: "Writer", text: "Drafted a feed post about the rebalance.", tone: "bg-info-soft text-info-text", },];
export default function Demo() { return ( <div className="flex w-full max-w-md flex-col gap-4"> {agents.map((a) => ( <Message from="assistant" key={a.name}> <MessageAvatar> <span className={`flex size-6 items-center justify-center rounded-full border border-border text-xs font-medium ${a.tone}`} > {a.name[0]} </span> </MessageAvatar> <MessageHeader> <span className="font-semibold text-foreground">{a.name}</span> agent </MessageHeader> <MessageContent>{a.text}</MessageContent> </Message> ))} </div> );}<script setup lang="ts">import { Message, MessageAvatar, MessageContent, MessageHeader,} from "@/components/ai/message";
const agents = [ { name: "Keeper", text: "Drift is 2.4%, above the 2% limit. Rebalance is allowed.", tone: "bg-success-soft text-success-text", }, { name: "Writer", text: "Drafted a feed post about the rebalance.", tone: "bg-info-soft text-info-text", },];</script>
<template> <div class="flex w-full max-w-md flex-col gap-4"> <Message v-for="a in agents" :key="a.name" from="assistant"> <MessageAvatar> <span class="flex size-6 items-center justify-center rounded-full border border-border text-xs font-medium" :class="a.tone" > {{ a.name[0] }} </span> </MessageAvatar> <MessageHeader> <span class="font-semibold text-foreground">{{ a.name }}</span> agent </MessageHeader> <MessageContent>{{ a.text }}</MessageContent> </Message> </div></template><script lang="ts"> import { Message, MessageAvatar, MessageContent, MessageHeader, } from "$lib/components/ai/message";
const agents = [ { name: "Keeper", text: "Drift is 2.4%, above the 2% limit. Rebalance is allowed.", tone: "bg-success-soft text-success-text", }, { name: "Writer", text: "Drafted a feed post about the rebalance.", tone: "bg-info-soft text-info-text", }, ];</script>
<div class="flex w-full max-w-md flex-col gap-4"> {#each agents as a (a.name)} <Message from="assistant"> <MessageAvatar> <span class="flex size-6 items-center justify-center rounded-full border border-border text-xs font-medium {a.tone}" > {a.name[0]} </span> </MessageAvatar> <MessageHeader> <span class="font-semibold text-foreground">{a.name}</span> agent </MessageHeader> <MessageContent>{a.text}</MessageContent> </Message> {/each}</div>| Part | Prop | Type | Default |
|---|---|---|---|
| Message | from |
user | assistant | system |
|
| MessageContent | variant |
Bubble variant |
secondary (user), ghost (assistant) |
| MessageResponse | Streamdown props |
markdown string as children |
|
| MessageAction | tooltip, label |
string |
|
| MessageBranch | defaultBranch, onBranchChange |
number, fn |
0 |
| MessageToolbar, MessageActions, MessageBranchSelector/Previous/Next/Page | |||
| MessageAvatar, MessageHeader | opt-in (multi-agent) |
MessageResponse uses Streamdown; add @source "../node_modules/streamdown/dist/*.js"; to your Tailwind CSS so its classes are generated.
Vue and Svelte
Section titled “Vue and Svelte”MessageResponsetakes the markdown ascontent(Vue also accepts the default slot text, Svelte thecontentprop only). Vue renders withvue-stream-markdown, Svelte withsvelte-streamdown; the typography above is applied through each library’s own hooks, so code-block styling follows the library defaults.MessageBranchContent: every direct child is one branch (Reactchildren, Vue slot children, Svelte{#each}children). Svelte counts the rendered children and hides all but the current one.- Branch change: React
onBranchChange, Vue@branch-change, SvelteonBranchChange.
✦ Edmi additions
Section titled “✦ Edmi additions”- No assistant avatar by default: the response is full width on the left, the user turn is a secondary bubble on the right.
- Status and shimmer text goes inside
MessageContent(a ghost bubble) so its first line aligns like any message. MessageResponsetypography: 15px / 1.65, 600 lead-ins, h1-h3 22/18/16, inline code in mono 12.5 on--mutedwith--destructive-text, links--info-textunderlined, 3px--borderquote rule, 22px list indent, max ~68ch.MessageAvatar+MessageHeaderfor multi-agent chats: the avatar aligns to the name line.