Skip to content
edmi-ui

Message

One chat turn: secondary user bubble or full-width assistant response with markdown typography, actions, branches and toolbar.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Rebalance if drift is above 2%.

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: .

Rebalance if drift is above 2%.
Rebalance if drift is above 2%.

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.

Built on message and bubble.

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

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-message
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-message
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-message
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-message

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-message.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-message.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-message.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-message.json

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

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>
registry/components/ai/message.tsx
"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>
);
registry/components/ai/message/Message.vue
<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>
registry/components/ai/message/MessageAction.vue
<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>
registry/components/ai/message/MessageActions.vue
<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>
registry/components/ai/message/MessageBranch.vue
<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>
registry/components/ai/message/MessageBranchContent.vue
<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>
registry/components/ai/message/MessageBranchNext.vue
<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>
registry/components/ai/message/MessageBranchPage.vue
<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>
registry/components/ai/message/MessageBranchPrevious.vue
<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>
registry/components/ai/message/MessageBranchSelector.vue
<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>
registry/components/ai/message/MessageContent.vue
<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>
registry/components/ai/message/MessageResponse.vue
<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>
registry/components/ai/message/MessageToolbar.vue
<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>
registry/components/ai/message/context.ts
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;
}
registry/components/ai/message/index.ts
// 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 };
src/lib/registry/ai/message/index.ts
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,
};
src/lib/registry/ai/message/message-action.svelte
<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}
src/lib/registry/ai/message/message-actions.svelte
<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>
src/lib/registry/ai/message/message-branch-content.svelte
<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>
src/lib/registry/ai/message/message-branch-next.svelte
<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>
src/lib/registry/ai/message/message-branch-page.svelte
<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>
src/lib/registry/ai/message/message-branch-previous.svelte
<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>
src/lib/registry/ai/message/message-branch-selector.svelte
<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}
src/lib/registry/ai/message/message-branch.svelte
<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>
src/lib/registry/ai/message/message-content.svelte
<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>
src/lib/registry/ai/message/message-response.svelte
<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>
src/lib/registry/ai/message/message-toolbar.svelte
<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>
src/lib/registry/ai/message/message.svelte
<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>
src/lib/registry/ai/message/use-message.svelte.ts
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;
}
StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sell 0.42 NVDAx and buy the other two.

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%.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
K
Keeperagent
Drift is 2.4%, above the 2% limit. Rebalance is allowed.
W
Writeragent
Drafted a feed post about the rebalance.
K
Keeper agent
Drift is 2.4%, above the 2% limit. Rebalance is allowed.
W
Writer agent
Drafted a feed post about the rebalance.
K
Keeper agent
Drift is 2.4%, above the 2% limit. Rebalance is allowed.
W
Writer agent
Drafted a feed post about the rebalance.
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.

  • MessageResponse takes the markdown as content (Vue also accepts the default slot text, Svelte the content prop only). Vue renders with vue-stream-markdown, Svelte with svelte-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 (React children, 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, Svelte onBranchChange.
  • 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.
  • MessageResponse typography: 15px / 1.65, 600 lead-ins, h1-h3 22/18/16, inline code in mono 12.5 on --muted with --destructive-text, links --info-text underlined, 3px --border quote rule, 22px list indent, max ~68ch.
  • MessageAvatar + MessageHeader for multi-agent chats: the avatar aligns to the name line.