Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/www/src/content/docs/ai-elements/message/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client';

export const preview = {
type: 'code',
code: `<Flex direction="column" gap={5} style={{ width: 440 }}>
<Message>
<Message.Avatar>
<Avatar size={3} radius="full" fallback="A" color="indigo" />
</Message.Avatar>
<Message.Header>Assistant</Message.Header>
<Message.Content>
<Message.Bubble variant="outline">Here's the summary you asked for.</Message.Bubble>
</Message.Content>
<Message.Footer>1:52 AM</Message.Footer>
</Message>
<Message align="end">
<Message.Content>
<Message.Bubble>Thanks — looks great!</Message.Bubble>
</Message.Content>
</Message>
</Flex>`
};

export const anatomyDemo = {
type: 'code',
code: `<Flex direction="column" gap={5} style={{ width: 440 }}>
<Message>
<Message.Avatar>
<Avatar size={3} radius="full" fallback="A" color="indigo" />
</Message.Avatar>
<Message.Header>Assistant</Message.Header>
<Message.Content>
<Message.Bubble variant="outline">Hover me for actions.</Message.Bubble>
</Message.Content>
<Message.Footer>1:52 AM</Message.Footer>
<Message.Actions>
<IconButton size={1} aria-label="Copy message">⧉</IconButton>
</Message.Actions>
</Message>
<Message align="end">
<Message.Content>
<Message.Bubble>Me too — actions on my side.</Message.Bubble>
</Message.Content>
<Message.Actions>
<IconButton size={1} aria-label="Edit message">✎</IconButton>
</Message.Actions>
</Message>
</Flex>`
};

export const groupDemo = {
type: 'code',
code: `<Flex direction="column" gap={5} style={{ width: 440 }}>
<Message.Group>
<Message>
<Message.Header>Ana</Message.Header>
<Message.Content>
<Message.Bubble variant="outline">Pushed the fix.</Message.Bubble>
</Message.Content>
</Message>
<Message>
<Message.Content>
<Message.Bubble variant="outline">CI is green again.</Message.Bubble>
</Message.Content>
</Message>
<Message>
<Message.Avatar>
<Avatar size={3} radius="full" fallback="A" color="indigo" />
</Message.Avatar>
<Message.Content>
<Message.Bubble variant="outline">Deploying now.</Message.Bubble>
</Message.Content>
<Message.Footer>1:54 AM</Message.Footer>
</Message>
</Message.Group>
<Message.Group>
<Message align="end">
<Message.Content>
<Message.Bubble>Nice, thank you!</Message.Bubble>
</Message.Content>
</Message>
<Message align="end">
<Message.Content>
<Message.Bubble>I'll verify on staging.</Message.Bubble>
</Message.Content>
<Message.Footer>1:55 AM</Message.Footer>
</Message>
</Message.Group>
</Flex>`
};

export const bubbleVariantsDemo = {
type: 'code',
tabs: [
{
name: 'Solid',
code: `<Flex direction="column" gap={3} align="start">
<Message.Bubble>The default: solid neutral.</Message.Bubble>
<Message.Bubble color="accent">High-emphasis accent bubble.</Message.Bubble>
<Message.Bubble color="danger">Something went wrong.</Message.Bubble>
</Flex>`
},
{
name: 'Outline',
code: `<Flex direction="column" gap={3} align="start">
<Message.Bubble variant="outline">Bordered bubble on the base background.</Message.Bubble>
<Message.Bubble variant="outline" color="accent">Accent outline bubble.</Message.Bubble>
<Message.Bubble variant="outline" color="danger">Danger outline bubble.</Message.Bubble>
</Flex>`
}
]
};
85 changes: 85 additions & 0 deletions apps/www/src/content/docs/ai-elements/message/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: Message
description: Inert message layout — avatar, header, content, footer, hover actions and bubbles, aligned to either side of a conversation.
source: packages/raystack/components/message
tag: new
---

import { preview, anatomyDemo, groupDemo, bubbleVariantsDemo } from "./demo.ts";

<Demo data={preview} />

## Anatomy

```tsx
import { Message } from '@raystack/apsara'

<Message align="end">
<Message.Avatar>{/* Avatar */}</Message.Avatar>
<Message.Header>Ana</Message.Header>
<Message.Content>
<Message.Bubble>Hi there!</Message.Bubble>
</Message.Content>
<Message.Footer>1:52 AM</Message.Footer>
<Message.Actions>{/* copy / edit */}</Message.Actions>
</Message>
```

`Message` is pure layout — no context, no effects, no chat coupling. It works
anywhere: inside a `Chat` scroller, a comment thread, or a standalone
transcript. Scroll behavior (visibility tracking, turn anchoring) belongs to
[Chat](/docs/ai-elements/chat) — wrap a message in `Chat.Item` to opt in.

## API Reference

### Message

One message. `align` drives which side everything sits on.

<auto-type-table path="./props.ts" name="MessageProps" />

Sub-parts are layout slots: `Message.Avatar` (bottom-aligned beside the
message), `Message.Header` (sender line), `Message.Content` (the body
column), `Message.Footer` (timestamp line) and `Message.Actions`
(hover-revealed controls beside the bubble; always visible on touch devices).

### Message.Group

A flex column that pulls consecutive messages from the same sender closer
together. Render the avatar and footer once — typically on the group's last
message.

### Message.Bubble

The message surface.

<auto-type-table path="./props.ts" name="MessageBubbleProps" />

## Examples

### Message anatomy

Avatar, header, footer and hover-revealed actions around the content column.

<Demo data={anatomyDemo} />

### Grouped messages

Consecutive messages from one sender share a group; the avatar and timestamp
appear once.

<Demo data={groupDemo} />

### Bubble variants

`variant` picks the surface treatment (`solid` or `outline`) and `color` the
palette (`neutral`, `accent` or `danger`).

<Demo data={bubbleVariantsDemo} />

## Accessibility

- `Message.Actions` reveal on hover **and** on focus-within, and stay visible
on touch devices, so keyboard and touch users are not locked out.
- Message bodies are regular document content — screen readers announce them
in reading order with no extra semantics to configure.
29 changes: 29 additions & 0 deletions apps/www/src/content/docs/ai-elements/message/props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export interface MessageProps {
/**
* Which side of the conversation the message sits on. `"end"` aligns the
* message to the trailing edge (the sender's own messages); `"start"` to
* the leading edge.
* @defaultValue "start"
*/
align?: 'start' | 'end';

/** Custom CSS class names. */
className?: string;
}

export interface MessageBubbleProps {
/**
* Visual style of the message surface.
* @defaultValue "solid"
*/
variant?: 'solid' | 'outline';

/**
* Color of the message surface.
* @defaultValue "neutral"
*/
color?: 'accent' | 'neutral' | 'danger';

/** Custom CSS class names. */
className?: string;
}
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/ai-elements/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "AI Elements",
"pages": ["prompt-input"]
"pages": ["message", "prompt-input"]
}
97 changes: 97 additions & 0 deletions packages/raystack/components/message/__tests__/message.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it } from 'vitest';
import { Message } from '../message';
import styles from '../message.module.css';

describe('Message', () => {
it('renders alignment as a data attribute', () => {
render(
<Message align='end' data-testid='message'>
hi
</Message>
);
expect(screen.getByTestId('message')).toHaveAttribute('data-align', 'end');
});

it('defaults to start alignment', () => {
render(<Message data-testid='message'>hi</Message>);
expect(screen.getByTestId('message')).toHaveAttribute(
'data-align',
'start'
);
});

it('renders all sub-parts with their classes', () => {
render(
<Message data-testid='message'>
<Message.Avatar data-testid='avatar'>A</Message.Avatar>
<Message.Header data-testid='header'>Ana</Message.Header>
<Message.Content data-testid='content'>body</Message.Content>
<Message.Footer data-testid='footer'>1:52</Message.Footer>
<Message.Actions data-testid='actions'>
<button type='button'>Copy</button>
</Message.Actions>
</Message>
);

expect(screen.getByTestId('avatar')).toHaveClass(styles['message-avatar']);
expect(screen.getByTestId('header')).toHaveClass(styles['message-header']);
expect(screen.getByTestId('content')).toHaveClass(
styles['message-content']
);
expect(screen.getByTestId('footer')).toHaveClass(styles['message-footer']);
expect(screen.getByTestId('actions')).toHaveClass(
styles['message-actions']
);
});

it('wraps consecutive messages in a group', () => {
render(
<Message.Group data-testid='group'>
<Message>first</Message>
<Message>second</Message>
</Message.Group>
);
expect(screen.getByTestId('group')).toHaveClass(styles.group);
expect(screen.getByText('first')).toBeInTheDocument();
expect(screen.getByText('second')).toBeInTheDocument();
});

it('forwards ref', () => {
const ref = createRef<HTMLDivElement>();
render(<Message ref={ref}>hi</Message>);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});

describe('Message.Bubble', () => {
it('defaults to the solid neutral appearance', () => {
render(<Message.Bubble data-testid='bubble'>text</Message.Bubble>);
const bubble = screen.getByTestId('bubble');
expect(bubble).toHaveAttribute('data-variant', 'solid');
expect(bubble).toHaveAttribute('data-color', 'neutral');
expect(bubble).toHaveClass(styles['bubble-solid']);
expect(bubble).toHaveClass(styles['bubble-neutral']);
});

it.each([
['solid', 'accent'],
['solid', 'neutral'],
['solid', 'danger'],
['outline', 'accent'],
['outline', 'neutral'],
['outline', 'danger']
] as const)('renders the %s %s appearance', (variant, color) => {
render(
<Message.Bubble data-testid='bubble' variant={variant} color={color}>
text
</Message.Bubble>
);
const bubble = screen.getByTestId('bubble');
expect(bubble).toHaveAttribute('data-variant', variant);
expect(bubble).toHaveAttribute('data-color', color);
expect(bubble).toHaveClass(styles[`bubble-${variant}`]);
expect(bubble).toHaveClass(styles[`bubble-${color}`]);
});
});
});
2 changes: 2 additions & 0 deletions packages/raystack/components/message/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Message, type MessageAlign, type MessageProps } from './message';
export type { MessageBubbleProps } from './message-bubble';
56 changes: 56 additions & 0 deletions packages/raystack/components/message/message-bubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client';

import { cva, type VariantProps } from 'class-variance-authority';
import { ComponentProps } from 'react';
import styles from './message.module.css';

const bubble = cva(styles.bubble, {
variants: {
variant: {
solid: styles['bubble-solid'],
outline: styles['bubble-outline']
},
color: {
accent: styles['bubble-accent'],
neutral: styles['bubble-neutral'],
danger: styles['bubble-danger']
}
},
defaultVariants: {
variant: 'solid',
color: 'neutral'
}
});

export interface MessageBubbleProps
extends ComponentProps<'div'>,
VariantProps<typeof bubble> {
/**
* Visual style of the message surface.
* @defaultValue "solid"
*/
variant?: 'solid' | 'outline';
/**
* Color of the message surface.
* @defaultValue "neutral"
*/
color?: 'accent' | 'neutral' | 'danger';
}

export function MessageBubble({
className,
variant = 'solid',
color = 'neutral',
...props
}: MessageBubbleProps) {
return (
<div
data-variant={variant}
data-color={color}
className={bubble({ variant, color, className })}
{...props}
/>
);
}

MessageBubble.displayName = 'Message.Bubble';
Loading
Loading