Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/www/src/components/docs/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default function DocsSearch({ pageTree }: { pageTree: Root }) {
excluded until the user searches. */
const items = flattened.reduce<Record<string, Item[]>>((acc, item) => {
const folder = getFolderFromUrl(item.url);
if (folder === 'components') return acc;
if (folder === 'components' || folder === 'ai-elements') return acc;
if (!acc[folder]) {
acc[folder] = [];
}
Expand Down
147 changes: 147 additions & 0 deletions apps/www/src/content/docs/ai-elements/chat-panel/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use client';

export const preview = {
type: 'code',
code: `function ChatPanelPreview() {
const [mode, setMode] = React.useState('docked');

return (
<Flex style={{ width: '100%', height: 420, border: '0.5px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-4)', overflow: 'hidden' }}>
<Flex direction="column" gap={3} style={{ flex: 1, padding: 'var(--rs-space-5)' }}>
<Text size="large" weight="medium">Main content</Text>
<Text size="small" variant="secondary">
The docked panel is a real flex sibling — it squeezes this content
instead of covering it. Pop it out or minimize it from the header;
floating and minimized modes are fixed to the browser viewport.
</Text>
</Flex>
<ChatPanel mode={mode} onModeChange={setMode} side="right">
<ChatPanel.Header>
<ChatPanel.Title>Create task in design system 2</ChatPanel.Title>
<ChatPanel.Actions>
<ChatPanel.MinimizeTrigger />
<ChatPanel.ExpandTrigger />
</ChatPanel.Actions>
</ChatPanel.Header>
<ChatPanel.Content>
<Chat>
<Chat.Messages>
<Chat.Item>
<Message align="end">
<Message.Content>
<Message.Bubble>create a task in design system 2</Message.Bubble>
</Message.Content>
</Message>
</Chat.Item>
<Chat.Item>
<Message>
<Message.Content>
<Text size="small">Done — I created the task 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, event) => event.currentTarget.reset()}>
<PromptInput.Textarea placeholder="Reply…" />
<PromptInput.Footer>
<PromptInput.Submit />
</PromptInput.Footer>
</PromptInput>
</div>
</Chat>
</ChatPanel.Content>
<ChatPanel.Trigger />
</ChatPanel>
</Flex>
);
}`
};

export const controlledDemo = {
type: 'code',
code: `function ControlledChatPanel() {
const [mode, setMode] = React.useState('docked');

return (
<Flex direction="column" gap={4} style={{ width: '100%' }}>
<Flex gap={3}>
<Button size="small" variant="outline" color="neutral" onClick={() => setMode('docked')}>Dock</Button>
<Button size="small" variant="outline" color="neutral" onClick={() => setMode('floating')}>Float</Button>
<Button size="small" variant="outline" color="neutral" onClick={() => setMode('minimized')}>Minimize</Button>
</Flex>
<Text size="small" variant="secondary">mode: {mode}</Text>
<Flex style={{ width: '100%', height: 360, border: '0.5px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-4)', overflow: 'hidden' }}>
<Flex style={{ flex: 1, padding: 'var(--rs-space-5)' }}>
<Text size="small" variant="secondary">
Floating and minimized modes leave this frame and pin to the
browser viewport.
</Text>
</Flex>
<ChatPanel mode={mode} onModeChange={setMode} side="right" defaultSize={{ width: 360, height: 440 }}>
<ChatPanel.Header>
<ChatPanel.Title>Assistant</ChatPanel.Title>
<ChatPanel.Actions>
<ChatPanel.MinimizeTrigger />
<ChatPanel.ExpandTrigger />
</ChatPanel.Actions>
</ChatPanel.Header>
<ChatPanel.Content>
<Flex style={{ padding: 'var(--rs-space-5)' }}>
<Text size="small" variant="secondary">
Drag the header to move the floating window; resize from any
edge or corner.
</Text>
</Flex>
</ChatPanel.Content>
<ChatPanel.Trigger />
</ChatPanel>
</Flex>
</Flex>
);
}`
};

export const unreadBadgeDemo = {
type: 'code',
code: `function MinimizedWithBadge() {
const [mode, setMode] = React.useState('minimized');

// The transform makes the frame the containing block for the panel's
// fixed positioning, so this demo's trigger pins to the frame corner
// instead of stacking on the page's other panels.
return (
<Flex direction="column" gap={4} style={{ width: '100%' }}>
<Text size="small" variant="secondary">
The minimized trigger is a slot — compose Indicator or Badge for
unread counts. Look at the bottom-right of this frame.
</Text>
<Flex style={{ width: '100%', height: 280, border: '0.5px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-4)', overflow: 'hidden', transform: 'translateZ(0)' }}>
<Flex style={{ flex: 1, padding: 'var(--rs-space-5)' }}>
<Text size="small" variant="secondary">
Click the bubble to restore the panel; minimize it again from the
header.
</Text>
</Flex>
<ChatPanel mode={mode} onModeChange={setMode} side="right">
<ChatPanel.Header>
<ChatPanel.Title>Assistant</ChatPanel.Title>
<ChatPanel.Actions>
<ChatPanel.MinimizeTrigger />
</ChatPanel.Actions>
</ChatPanel.Header>
<ChatPanel.Content>
<Flex style={{ padding: 'var(--rs-space-5)' }}>
<Text size="small">Minimize me again from the header.</Text>
</Flex>
</ChatPanel.Content>
<ChatPanel.Trigger aria-label="Open chat, 3 unread messages">
<Indicator label="3">💬</Indicator>
</ChatPanel.Trigger>
</ChatPanel>
</Flex>
</Flex>
);
}`
};
136 changes: 136 additions & 0 deletions apps/www/src/content/docs/ai-elements/chat-panel/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: ChatPanel
description: A dockable, floating, minimizable window frame for chat — an inline sidebar that pops out to a draggable, resizable window or collapses to a corner bubble.
source: packages/raystack/components/chat-panel
tag: new
---

import { preview, controlledDemo, unreadBadgeDemo } from "./demo.ts";

<Demo data={preview} />

## Anatomy

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

<Flex>
<MainContent />
<ChatPanel mode={mode} onModeChange={setMode} side="right">
<ChatPanel.Header>
<ChatPanel.Title>Create task in design system 2</ChatPanel.Title>
<ChatPanel.Actions>
{/* consumer extras, e.g. ⋯ menu */}
<ChatPanel.MinimizeTrigger />
<ChatPanel.ExpandTrigger />
</ChatPanel.Actions>
</ChatPanel.Header>
<ChatPanel.Content>{/* Chat.Messages + PromptInput */}</ChatPanel.Content>
<ChatPanel.Trigger /> {/* minimized bubble */}
</ChatPanel>
</Flex>
```

The panel mounts **once** in your layout and morphs between modes with pure
CSS — no portal, no remount, so chat state (scroll position, focus, streams)
survives every transition:

- **`docked`** — an in-flow sidebar (a flex sibling, like `SidePanel`) that
squeezes the main content rather than covering it.
- **`floating`** — the same element switched to `position: fixed`: drag it by
the header, resize it from any edge or corner, both viewport-clamped.
- **`minimized`** — the frame collapses to `ChatPanel.Trigger`, a fixed
corner bubble; clicking it restores the previous mode. If you don't render
a trigger, minimized shows nothing and your app supplies its own affordance.

There is deliberately no close (×) — header actions are slottable, so apps
add their own controls next to the ready-made mode triggers.

Because floating and minimized rely on `position: fixed`, mount the panel
near the layout root: an ancestor with `transform`, `filter` or
`backdrop-filter` would break fixed positioning.

## API Reference

### Root

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

### Header

The title bar. In floating mode it is the drag handle — pointer drags on it
move the window (interactive children like buttons are excluded, as is
anything marked `data-chat-panel-no-drag`).

### Title

The heading (`<h2>`), truncated with an ellipsis.

### Actions

A slot row at the end of the header for controls.

### MinimizeTrigger / ExpandTrigger

Ready-made mode buttons for `ChatPanel.Actions`, built on `IconButton`.
MinimizeTrigger switches to `minimized`; ExpandTrigger toggles between
`docked` and `floating` with a matching icon and accessible name.

### Content

The body wrapper — a flex column that gives `Chat.Messages` its bounded
height.

### Trigger

The minimized-state corner bubble.

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

## Examples

### Controlled mode

Control `mode` to persist it, or to open the panel from your own UI. The
floating window here is fixed to the browser viewport — pop it out and drag
its header.

<Demo data={controlledDemo} />

### Unread badge on the bubble

<Demo data={unreadBadgeDemo} />

### Persisting placement

Position and size are controllable for apps that restore the floating window
across sessions:

```tsx
<ChatPanel
mode={mode}
onModeChange={setMode}
position={saved.position}
onPositionChange={savePosition}
size={saved.size}
onSizeChange={saveSize}
minSize={{ width: 320, height: 400 }}
>
</ChatPanel>
```

## Accessibility

- The root renders an `<aside>` (`complementary` landmark); give it an
`aria-label` when your page has more than one.
- `ChatPanel.Title` is a real `<h2>` heading.
- All mode triggers are `IconButton`s with descriptive, state-aware
accessible names ("Minimize chat panel", "Pop out chat panel", "Dock chat
panel", "Open chat").
- Mode transitions never remount the content, so keyboard focus inside the
thread or composer is preserved when docking or popping out.
- Dragging clamps so the header can never leave the viewport, and the window
re-clamps when the browser is resized.
- Drag and resize are pointer gestures; keep docked mode reachable (the
ExpandTrigger toggle) so keyboard users can always use the inline layout.
85 changes: 85 additions & 0 deletions apps/www/src/content/docs/ai-elements/chat-panel/props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type React from 'react';

export interface ChatPanelPosition {
x: number;
y: number;
}

export interface ChatPanelSize {
width: number;
height: number;
}

export interface ChatPanelProps {
/** Presentation mode of the panel (controlled). */
mode?: 'docked' | 'floating' | 'minimized';

/**
* Initial mode when uncontrolled.
* @defaultValue "docked"
*/
defaultMode?: 'docked' | 'floating' | 'minimized';

/** Called when the mode changes. */
onModeChange?: (mode: 'docked' | 'floating' | 'minimized') => void;

/**
* Which edge the panel docks to; also picks the corner used by the
* floating default position and the minimized trigger.
* @defaultValue "right"
*/
side?: 'left' | 'right';

/** Floating window position in viewport pixels (controlled). */
position?: ChatPanelPosition | null;

/**
* Initial floating position when uncontrolled. When omitted the window
* starts at the bottom corner on the docked `side`.
*/
defaultPosition?: ChatPanelPosition;

/** Called when dragging or resizing moves the floating window. */
onPositionChange?: (position: ChatPanelPosition) => void;

/** Floating window size in pixels (controlled). */
size?: ChatPanelSize;

/**
* Initial floating size when uncontrolled.
* @defaultValue { width: 400, height: 560 }
*/
defaultSize?: ChatPanelSize;

/** Called when resizing changes the floating window size. */
onSizeChange?: (size: ChatPanelSize) => void;

/**
* Smallest allowed floating size.
* @defaultValue { width: 280, height: 320 }
*/
minSize?: ChatPanelSize;

/** Largest allowed floating size. Defaults to the viewport. */
maxSize?: ChatPanelSize;

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

export interface ChatPanelTriggerProps {
/**
* Bubble content. Defaults to a chat icon; compose `Indicator` or `Badge`
* for unread counts.
*/
children?: React.ReactNode;

/**
* Accessible name of the bubble.
* @defaultValue "Open chat"
*/
'aria-label'?: string;

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