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
130 changes: 130 additions & 0 deletions apps/www/src/content/docs/ai-elements/chat/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
'use client';

export const preview = {
type: 'code',
code: `<Flex direction="column" style={{ width: 440, height: 420, border: '0.5px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-4)' }}>
<Chat>
<Chat.Messages>
<Chat.Separator>Today 1:52 AM</Chat.Separator>
<Chat.Item messageId="m1">
<Message align="end">
<Message.Content>
<Message.Bubble>create a task in design system 2</Message.Bubble>
</Message.Content>
</Message>
</Chat.Item>
<Chat.Item messageId="m2">
<Message>
<Message.Content>
<Reasoning duration={10}>
<Reasoning.Trigger />
<Reasoning.Content>
<Reasoning.Step label="Gathering project context">
<Text size="small" variant="secondary">Looked at recent issues in Design System 2.</Text>
</Reasoning.Step>
<Reasoning.Step label="Creating the task" />
</Reasoning.Content>
</Reasoning>
<Text size="small">I created the task in Design System 2 and assigned it to you.</Text>
</Message.Content>
</Message>
</Chat.Item>
<Chat.JumpButton />
</Chat.Messages>
<div style={{ padding: 'var(--rs-space-3)' }}>
<PromptInput onSubmit={value => console.log(value)}>
<PromptInput.Textarea placeholder="Reply…" />
<PromptInput.Footer>
<PromptInput.Submit />
</PromptInput.Footer>
</PromptInput>
</div>
</Chat>
</Flex>`
};

export const attachmentDemo = {
type: 'code',
code: `<Flex direction="column" gap={3} align="start">
<Chat.Attachment title="design-spec.pdf" description="1.2 MB" onRemove={() => {}} />
<Chat.Attachment title="screenshot.png" description="Uploading…" state="uploading" />
<Chat.Attachment title="notes.txt" description="Upload failed" state="error" onRemove={() => {}} />
</Flex>`
};

export const streamingDemo = {
type: 'code',
code: `function StreamingChat() {
const [messages, setMessages] = React.useState([
{ id: 'm1', align: 'end', text: 'What changed this week?' },
{ id: 'm2', align: 'start', text: 'Three issues moved to done.' }
]);
const timersRef = React.useRef([]);

React.useEffect(() => () => timersRef.current.forEach(clearTimeout), []);

const send = value => {
const id = 'u' + Date.now();
setMessages(current => [
...current,
{ id, align: 'end', text: value, anchor: true },
{ id: id + '-reply', align: 'start', text: '' }
]);
// Simulate a token stream into the reply.
const words = 'Sure — streaming replies keep the anchored question in view while text arrives below it, and the ↓ Latest pill appears once the live edge scrolls out of view.'.split(' ');
words.forEach((word, index) => {
timersRef.current.push(
setTimeout(() => {
setMessages(current =>
current.map(message =>
message.id === id + '-reply'
? { ...message, text: message.text + ' ' + word }
: message
)
);
}, 120 * (index + 1))
);
});
};

return (
<Flex direction="column" style={{ width: 440, height: 360, border: '0.5px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-4)' }}>
<Chat>
<Chat.Messages>
{messages.map(message => (
<Chat.Item
key={message.id}
messageId={message.id}
scrollAnchor={message.anchor}
>
<Message align={message.align}>
<Message.Content>
{message.align === 'end' ? (
<Message.Bubble>{message.text}</Message.Bubble>
) : (
<Text size="small">{message.text}</Text>
)}
</Message.Content>
</Message>
</Chat.Item>
))}
<Chat.JumpButton />
</Chat.Messages>
<div style={{ padding: 'var(--rs-space-3)' }}>
<PromptInput
onSubmit={(value, event) => {
event.currentTarget.reset();
send(value);
}}
>
<PromptInput.Textarea placeholder="Ask something…" />
<PromptInput.Footer>
<PromptInput.Submit />
</PromptInput.Footer>
</PromptInput>
</div>
</Chat>
</Flex>
);
}`
};
129 changes: 129 additions & 0 deletions apps/www/src/content/docs/ai-elements/chat/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
title: Chat
description: The conversation scroller — live-edge following, turn anchoring, prepend preservation and visibility tracking, plus separators and attachments.
source: packages/raystack/components/chat
tag: new
---

import { preview, attachmentDemo, streamingDemo } from "./demo.ts";

<Demo data={preview} />

## Anatomy

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

<Chat>
<Chat.Messages>
<Chat.Separator>Today 1:52 AM</Chat.Separator>
<Chat.Item messageId="m1" scrollAnchor>
<Message align="end">
<Message.Content>
<Message.Bubble>create a task in design system 2</Message.Bubble>
</Message.Content>
</Message>
</Chat.Item>
<Chat.Item messageId="m2">
<Message>
<Message.Content>
<Reasoning duration={10}>
<Reasoning.Trigger />
<Reasoning.Content>
<Reasoning.Step label="Gathering ticket updates" />
</Reasoning.Content>
</Reasoning>
{/* consumer-rendered markdown / chips / cards */}
</Message.Content>
</Message>
</Chat.Item>
<Chat.JumpButton />
</Chat.Messages>
</Chat>
```

`Chat` owns scroll behavior; layout comes from
[Message](/docs/ai-elements/message) and
[Reasoning](/docs/ai-elements/reasoning), composed as children. `Chat.Item`
is the bridge: a neutral wrapper that registers whatever it wraps — a
message, a separator, a tool-call card — with the scroller. Message bodies
are children: markdown rendering, entity chips and embedded cards are
composed by your app — no markdown dependency enters the design system.
Consumers own messages, streaming and sending; everything here is
presentational.

## API Reference

### Chat

A flex-column container that sizes `Chat.Messages` against a composer sibling.

### Chat.Messages

The smart scroller. It follows streaming output while the reader is at the
bottom, stops following once they scroll up, anchors new `scrollAnchor` items
near the top of the viewport (ChatGPT/Linear style), and keeps the reading
position stable when older history is prepended.

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

Imperative commands are available through `actionsRef`:

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

### Chat.Item

The per-item behavior unit. It registers its element with the enclosing
`Chat.Messages` for visibility tracking and `scrollToMessage`, marks the turn
anchor, and applies `content-visibility: auto` containment so offscreen items
skip layout and paint. It wraps anything — it has no opinion about what a
message looks like.

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

### Chat.JumpButton

The floating "↓ Latest" pill. It appears once the reader leaves the live edge
and scrolls back down on click. Render it as a child of `Chat.Messages`;
children replace the default arrow-and-label content.

### useChatMessages

Hook for components rendered inside `Chat.Messages` — active-turn indicators,
jump-to-message navigation, custom scroll buttons.

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

### Chat.Separator

A centered label between hairlines — "Today 1:52 AM". Purely presentational;
format dates however your app does.

### Chat.Attachment

A presentational attachment preview card for `PromptInput.Header` or message
bodies. File picking, drag-drop and validation belong to your app.

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

## Examples

### Attachments

<Demo data={attachmentDemo} />

### Streaming and turn anchoring

Send a message: it anchors near the top while the reply streams below, and
the "↓ Latest" pill returns you to the live edge after scrolling up.

<Demo data={streamingDemo} />

## Accessibility

- The `Chat.Messages` viewport has `role="log"` with an accessible label, so
new messages are announced politely by screen readers.
- The jump button removes itself from the tab order (`tabindex="-1"`,
`aria-hidden`) while the reader is already at the live edge.
- Programmatic scrolling collapses to instant jumps under
`prefers-reduced-motion: reduce`.
117 changes: 117 additions & 0 deletions apps/www/src/content/docs/ai-elements/chat/props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type React from 'react';

export interface ChatMessagesProps {
/**
* Distance from the bottom, in pixels, within which the reader still
* counts as being at the live edge.
* @defaultValue 24
*/
bottomThreshold?: number;

/**
* Gap kept between the viewport top and an item anchored by
* `scrollAnchor` or scrolled to with `scrollToMessage`, in pixels.
* @defaultValue 12
*/
anchorOffset?: number;

/**
* Whether to follow new content while the reader is at the live edge.
* @defaultValue true
*/
autoScroll?: boolean;

/** A ref populated with the imperative scroll commands. */
actionsRef?: React.RefObject<ChatMessagesActions | null>;

/**
* Accessible label for the message log.
* @defaultValue "Conversation"
*/
'aria-label'?: string;

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

export interface ChatMessagesActions {
/** Scrolls the conversation to the live edge. */
scrollToBottom: (behavior?: ScrollBehavior) => void;

/** Scrolls the item registered with the given `messageId` into view. */
scrollToMessage: (
id: string,
options?: { behavior?: ScrollBehavior }
) => void;
}

export interface UseChatMessagesReturn {
/** Whether the reader is at the live edge (scrolled to the bottom). */
atBottom: boolean;

/**
* Ids of the registered items currently intersecting the viewport, in
* document order. Only items given a `messageId` are tracked.
*/
visibleMessageIds: string[];

/** Scrolls the conversation to the live edge. */
scrollToBottom: (behavior?: ScrollBehavior) => void;

/** Scrolls the item registered with the given `messageId` into view. */
scrollToMessage: (
id: string,
options?: { behavior?: ScrollBehavior }
) => void;
}

export interface ChatItemProps {
/**
* Stable id registered with the enclosing `Chat.Messages`, enabling
* visibility tracking and `scrollToMessage`.
*/
messageId?: string;

/**
* Marks the item as a scroll anchor: when it mounts inside
* `Chat.Messages`, the viewport scrolls it near the top so the reply can
* stream in below — set it on the latest user message.
* @defaultValue false
*/
scrollAnchor?: boolean;

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

export interface ChatAttachmentProps {
/** File name or main label. */
title?: React.ReactNode;

/** Secondary line — file size, type, or the error message. */
description?: React.ReactNode;

/**
* Content of the leading media square. Defaults to a file icon, or a
* spinner while `state` is `"uploading"`.
*/
media?: React.ReactNode;

/**
* Lifecycle of the attachment.
* @defaultValue "done"
*/
state?: 'uploading' | 'error' | 'done';

/** When provided, renders a remove button that calls it. */
onRemove?: () => void;

/**
* Accessible label for the remove button.
* @defaultValue "Remove attachment"
*/
removeLabel?: string;

/** 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": ["message", "prompt-input", "reasoning"]
"pages": ["chat", "message", "prompt-input", "reasoning"]
}
Loading
Loading