8.0k

Message

One turn. The person's message, and the agent's reply with its actions.

Epicenter

Local-first, open source apps

Special Sponsor
How do I add a settings dialog? It should open from the gear icon in the header.

Install Dialog, then open it from the gear button in site-header.tsx.

bash
npx shadcn@latest add dialog

Wrap the button in a DialogTrigger and put the settings form in DialogContent. Focus moves into the dialog when it opens, and Esc closes it.

<script lang="ts">
	import CheckIcon from "@lucide/svelte/icons/check";
	import CopyIcon from "@lucide/svelte/icons/copy";
	import PencilIcon from "@lucide/svelte/icons/pencil";
	import RefreshCcwIcon from "@lucide/svelte/icons/refresh-ccw";
	import ThumbsDownIcon from "@lucide/svelte/icons/thumbs-down";
	import ThumbsUpIcon from "@lucide/svelte/icons/thumbs-up";
	import { onDestroy, type Snippet } from "svelte";
	import * as Bubble from "$lib/components/ui/bubble/index.js";
	import * as Message from "$lib/components/ui/message/index.js";
	import * as Tooltip from "$lib/components/ui/tooltip/index.js";
	import Markdown from "$lib/components/markdown/markdown.svelte";
	import { Button } from "$lib/components/ui/button/index.js";
 
	const reply = `Install Dialog, then open it from the gear button in `site-header.tsx`.
 
```bash
npx shadcn@latest add dialog
```
 
Wrap the button in a `DialogTrigger` and put the settings form in `DialogContent`. Focus moves into the dialog when it opens, and **Esc** closes it.`;
 
	let copied = $state(false);
	let feedback = $state<"like" | "dislike" | null>(null);
	let timeout: ReturnType<typeof setTimeout> | undefined;
 
	function copy() {
		void navigator.clipboard.writeText(reply);
		copied = true;
		clearTimeout(timeout);
		timeout = setTimeout(() => (copied = false), 2000);
	}
 
	onDestroy(() => clearTimeout(timeout));
</script>
 
{#snippet messageAction(label: string, icon: Snippet, onclick?: () => void, pressed?: boolean)}
	<Tooltip.Root>
		<Tooltip.Trigger>
			{#snippet child({ props })}
				<Button
					{...props}
					variant="ghost"
					size="icon-sm"
					aria-label={label}
					aria-pressed={pressed}
					{onclick}
				>
					{@render icon()}
				</Button>
			{/snippet}
		</Tooltip.Trigger>
		<Tooltip.Content>
			<p>{label}</p>
		</Tooltip.Content>
	</Tooltip.Root>
{/snippet}
 
{#snippet copyIcon()}
	<CopyIcon />
{/snippet}
 
{#snippet copyStateIcon()}
	{#if copied}
		<CheckIcon />
	{:else}
		<CopyIcon />
	{/if}
{/snippet}
 
{#snippet editIcon()}
	<PencilIcon />
{/snippet}
 
{#snippet likeIcon()}
	<ThumbsUpIcon class={feedback === "like" ? "fill-current" : undefined} />
{/snippet}
 
{#snippet dislikeIcon()}
	<ThumbsDownIcon class={feedback === "dislike" ? "fill-current" : undefined} />
{/snippet}
 
{#snippet retryIcon()}
	<RefreshCcwIcon />
{/snippet}
 
<div class="mx-auto flex w-full max-w-md flex-col gap-8 py-6 text-sm">
	<Message.Root align="end">
		<Message.Content class="gap-2">
			<Bubble.Root align="end" variant="muted">
				<Bubble.Content>
					How do I add a settings dialog? It should open from the gear icon in the header.
				</Bubble.Content>
			</Bubble.Root>
			<Message.Footer>
				{@render messageAction("Copy", copyIcon)}
				{@render messageAction("Edit", editIcon)}
			</Message.Footer>
		</Message.Content>
	</Message.Root>
	<Message.Root>
		<Message.Content class="gap-2">
			<Markdown content={reply} />
			<Message.Footer class="px-0">
				{@render messageAction(copied ? "Copied" : "Copy", copyStateIcon, copy)}
				{@render messageAction(
					"Good response",
					likeIcon,
					() => (feedback = feedback === "like" ? null : "like"),
					feedback === "like"
				)}
				{@render messageAction(
					"Bad response",
					dislikeIcon,
					() => (feedback = feedback === "dislike" ? null : "dislike"),
					feedback === "dislike"
				)}
				{@render messageAction("Retry", retryIcon)}
			</Message.Footer>
		</Message.Content>
	</Message.Root>
</div>

Use Message for one turn in a thread. The person's text is a Bubble on a Message with align="end". The agent's reply is markdown on a Message, with actions under it. A thread of these turns is Chat.

Installation

Message is built with the Message, Bubble, and Tooltip components.

pnpm dlx shadcn-svelte@latest add message bubble tooltip

Usage

<script lang="ts">
  import CopyIcon from "@lucide/svelte/icons/copy";
  import * as Bubble from "$lib/components/ui/bubble/index.js";
  import { Button } from "$lib/components/ui/button/index.js";
  import * as Message from "$lib/components/ui/message/index.js";
</script>
<Message.Root align="end">
  <Message.Content>
    <Bubble.Root align="end" variant="muted">
      <Bubble.Content>How do I add a settings dialog?</Bubble.Content>
    </Bubble.Root>
  </Message.Content>
</Message.Root>
<Message.Root>
  <Message.Content class="gap-2">
    <p>Install Dialog, then open it from the gear button.</p>
    <Message.Footer>
      <Button variant="ghost" size="icon-sm" aria-label="Copy">
        <CopyIcon />
      </Button>
    </Message.Footer>
  </Message.Content>
</Message.Root>

See Message.

Actions

Put actions in Message.Footer. It follows the message alignment, so on the person's turn the actions sit on the right. Use ghost icon buttons with a tooltip for each.

  • Copy writes the reply to the clipboard and swaps to a check for two seconds.
  • Good response and Bad response are toggles. Set aria-pressed so screen readers announce the state.
  • Retry runs the turn again.
  • Edit on the person's turn puts the text back in the field.

Markdown

The reply streams as markdown. Render it with svelte-streamdown and route fenced code to a Code Block, so code in the reply gets line numbers and a copy button.

AI SDK

Branch on the message role. The person's turn is the bubble. The agent's turn is the reply, and chat.regenerate with the message id runs it again.

<script lang="ts">
  import { Chat } from "@ai-sdk/svelte";
 
  const chat = new Chat({});
</script>
 
{#each chat.messages as message (message.id)}
  {#if message.role === "user"}
    <Message.Root align="end">
      <Message.Content>
        <Bubble.Root align="end" variant="muted">
          <Bubble.Content>{getMessageText(message)}</Bubble.Content>
        </Bubble.Root>
      </Message.Content>
    </Message.Root>
  {:else}
    <Message.Root>
      <Message.Content class="gap-2">
        <Streamdown content={getMessageText(message)} />
        <Message.Footer>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label="Retry"
            onclick={() => chat.regenerate({ messageId: message.id })}
          >
            <RefreshCcwIcon />
          </Button>
        </Message.Footer>
      </Message.Content>
    </Message.Root>
  {/if}
{/each}